From 9210259511f92d6000b8c8ab414da32ede15e6e4 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Thu, 16 Apr 2026 21:46:40 +0300 Subject: [PATCH 1/7] feat: add Twilio SMS notification channel Adds Twilio SMS as a new notification channel, allowing users to receive SMS alerts via the Twilio API. Backend: provider using HTTP Basic Auth, type registration, validation, service wiring. Uses existing fields: address (Account SID), accessToken (Auth Token), homeserverUrl (From number), phone (To number). Frontend: form UI with 4 fields, validation, i18n keys. Tests: 18 provider tests + updated service test constructor. Closes #3095 Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/Hooks/useNotificationForm.ts | 10 ++ .../src/Pages/Notifications/create/index.tsx | 83 ++++++++++- client/src/Types/Notification.ts | 1 + client/src/Validation/notifications.ts | 9 ++ client/src/locales/en.json | 12 ++ server/src/config/services.ts | 3 + server/src/db/models/Notification.ts | 2 +- server/src/service/index.ts | 1 + .../notificationProviders/twilio.ts | 96 +++++++++++++ .../infrastructure/notificationsService.ts | 7 + server/src/types/notification.ts | 13 +- .../src/validation/notificationValidation.ts | 9 ++ .../notifications/twilioProvider.test.ts | 135 ++++++++++++++++++ .../services/notificationsService.test.ts | 3 + 14 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 server/src/service/infrastructure/notificationProviders/twilio.ts create mode 100644 server/test/unit/providers/notifications/twilioProvider.test.ts diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0b42b6d40..4a1e05f2c 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -60,6 +60,16 @@ function buildDefaults(data: Notification | null): NotificationFormData { address: data.address || "", }; } + if (data?.type === "twilio") { + return { + type: "twilio", + notificationName: data.notificationName || "", + address: data.address || "", + accessToken: data.accessToken || "", + phone: data.phone || "", + homeserverUrl: data.homeserverUrl || "", + }; + } if (data?.type === "pushover") { return { type: "pushover", diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 3da9e6f75..3d7f85acd 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -149,7 +149,8 @@ const NotificationsCreatePage = () => { /> {watchedType !== "matrix" && watchedType !== "telegram" && - watchedType !== "pushover" && ( + watchedType !== "pushover" && + watchedType !== "twilio" && ( { } /> )} + {watchedType === "twilio" && ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + } + /> + )} {watchedType === "matrix" && ( ; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c2e9ac854..7fbd7dcf2 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -967,6 +967,18 @@ "placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi", "optionUserKey": "User key", "placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG" + }, + "twilio": { + "title": "Twilio SMS configuration", + "description": "Configure Twilio to send SMS notifications to a phone number.", + "optionAccountSid": "Account SID", + "placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "optionAuthToken": "Auth token", + "placeholderAuthToken": "your_auth_token", + "optionFromNumber": "From number (Twilio)", + "placeholderFromNumber": "+15551234567", + "optionToNumber": "To number (recipient)", + "placeholderToNumber": "+15559876543" } }, "table": { diff --git a/server/src/config/services.ts b/server/src/config/services.ts index ed9f02a9f..8a5db6b49 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -30,6 +30,7 @@ import { TeamsProvider, TelegramProvider, PushoverProvider, + TwilioProvider, // Interfaces INetworkService, IEmailService, @@ -300,6 +301,7 @@ export const initializeServices = async ({ const teamsProvider = new TeamsProvider(logger); const telegramProvider = new TelegramProvider(logger); const pushoverProvider = new PushoverProvider(logger); + const twilioProvider = new TwilioProvider(logger); const notificationsService = new NotificationsService( notificationsRepository, @@ -313,6 +315,7 @@ export const initializeServices = async ({ teamsProvider, telegramProvider, pushoverProvider, + twilioProvider, settingsService, logger, notificationMessageBuilder diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 3b424bdde..973f7fe3e 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -25,7 +25,7 @@ const NotificationSchema = new Schema( }, type: { type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as NotificationChannel[], + enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[], required: true, }, notificationName: { diff --git a/server/src/service/index.ts b/server/src/service/index.ts index da529ed2b..5db8afc4f 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -31,6 +31,7 @@ export * from "@/service/infrastructure/notificationProviders/teams.js"; export * from "@/service/infrastructure/notificationProviders/webhook.js"; export * from "@/service/infrastructure/notificationProviders/telegram.js"; export * from "@/service/infrastructure/notificationProviders/pushover.js"; +export * from "@/service/infrastructure/notificationProviders/twilio.js"; // System services export * from "@/service/system/settingsService.js"; diff --git a/server/src/service/infrastructure/notificationProviders/twilio.ts b/server/src/service/infrastructure/notificationProviders/twilio.ts new file mode 100644 index 000000000..6d84f80f3 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/twilio.ts @@ -0,0 +1,96 @@ +const SERVICE_NAME = "TwilioProvider"; +import type { Notification } from "@/types/index.js"; +import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import got from "got"; + +export class TwilioProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + return false; + } + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + form: { + To: notification.phone, + From: notification.homeserverUrl, + Body: getTestMessage(), + }, + username: notification.address, + password: notification.accessToken, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Twilio test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + return false; + } + + const text = this.buildSmsText(message); + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + form: { + To: notification.phone, + From: notification.homeserverUrl, + Body: text, + }, + username: notification.address, + password: notification.accessToken, + ...this.gotRequestOptions(), + }); + + this.logger.info({ + message: "Twilio SMS notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Twilio SMS alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + private buildSmsText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.title); + lines.push(message.content.summary); + lines.push(""); + lines.push(`URL: ${message.monitor.url}`); + lines.push(`Status: ${message.monitor.status}`); + + if (message.content.thresholds && message.content.thresholds.length > 0) { + message.content.thresholds.forEach((breach) => { + lines.push(`${breach.metric.toUpperCase()}: ${breach.formattedValue}`); + }); + } + + return lines.join("\n"); + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 0344ab30c..90e9d637a 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -35,6 +35,7 @@ export class NotificationsService implements INotificationsService { private teamsProvider: INotificationProvider; private telegramProvider: INotificationProvider; private pushoverProvider: INotificationProvider; + private twilioProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; private notificationMessageBuilder: INotificationMessageBuilder; @@ -51,6 +52,7 @@ export class NotificationsService implements INotificationsService { teamsProvider: INotificationProvider, telegramProvider: INotificationProvider, pushoverProvider: INotificationProvider, + twilioProvider: INotificationProvider, settingsService: ISettingsService, logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder @@ -66,6 +68,7 @@ export class NotificationsService implements INotificationsService { this.teamsProvider = teamsProvider; this.telegramProvider = telegramProvider; this.pushoverProvider = pushoverProvider; + this.twilioProvider = twilioProvider; this.settingsService = settingsService; this.logger = logger; this.notificationMessageBuilder = notificationMessageBuilder; @@ -107,6 +110,8 @@ export class NotificationsService implements INotificationsService { return await this.telegramProvider.sendMessage!(notification, notificationMessage); case "pushover": return await this.pushoverProvider.sendMessage!(notification, notificationMessage); + case "twilio": + return await this.twilioProvider.sendMessage!(notification, notificationMessage); default: this.logger.warn({ message: `Unknown notification type: ${notification.type}`, @@ -171,6 +176,8 @@ export class NotificationsService implements INotificationsService { return await this.telegramProvider.sendTestAlert(notification); case "pushover": return await this.pushoverProvider.sendTestAlert(notification); + case "twilio": + return await this.twilioProvider.sendTestAlert(notification); default: return false; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index f095be770..f306e2592 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -1,4 +1,15 @@ -export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as const; +export const NotificationChannels = [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", + "teams", + "telegram", + "pushover", + "twilio", +] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; export interface Notification { diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 5cdb44e95..545335dad 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -79,6 +79,15 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ address: z.string().min(1, "User key is required"), accessToken: z.string().min(1, "App token is required"), }), + // Twilio SMS notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("twilio"), + address: z.string().min(1, "Account SID is required"), + accessToken: z.string().min(1, "Auth token is required"), + phone: z.string().min(1, "Recipient phone number is required"), + homeserverUrl: z.string().min(1, "Twilio phone number is required"), + }), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; diff --git a/server/test/unit/providers/notifications/twilioProvider.test.ts b/server/test/unit/providers/notifications/twilioProvider.test.ts new file mode 100644 index 000000000..6156b648f --- /dev/null +++ b/server/test/unit/providers/notifications/twilioProvider.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { TwilioProvider } = await import("../../../../src/service/infrastructure/notificationProviders/twilio.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new TwilioProvider(logger as any), logger }; +}; + +const makeTwilioNotification = (overrides?: Record) => + makeNotification({ + phone: "+15559876543", + homeserverUrl: "+15551234567", + ...overrides, + }); + +testNotificationProviderContract("TwilioProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeTwilioNotification(), +}); + +describe("TwilioProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Twilio API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + expect.stringContaining("api.twilio.com/2010-04-01/Accounts/"), + expect.objectContaining({ + form: expect.objectContaining({ To: "+15559876543", From: "+15551234567" }), + username: expect.any(String), + password: expect.any(String), + }) + ); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ address: "" }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false when phone is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ phone: "" }))).toBe(false); + }); + + it("returns false when homeserverUrl (from number) is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ homeserverUrl: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } })); + }); + + it("handles non-Error thrown values in sendTestAlert", async () => { + mockGotPost.mockRejectedValue("string error"); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } })); + }); + }); + + describe("sendMessage", () => { + it("sends SMS and returns true", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(true); + const form = mockGotPost.mock.calls[0][1].form; + expect(form.Body).toContain("Monitor Down"); + expect(form.To).toBe("+15559876543"); + expect(form.From).toBe("+15551234567"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false when phone is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ phone: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(42); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } })); + }); + + it("includes thresholds in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeTwilioNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].form.Body).toContain("CPU"); + }); + + it("includes monitor URL in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeTwilioNotification() as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].form.Body).toContain("https://example.com"); + }); + + it("omits thresholds when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + await provider.sendMessage(makeTwilioNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].form.Body; + expect(text).not.toContain("CPU"); + }); + }); +}); diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index 00dd644f8..d8ee4b05c 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -46,6 +46,7 @@ const createService = (overrides?: Record) => { const teamsProvider = createProvider(); const telegramProvider = createProvider(); const pushoverProvider = createProvider(); + const twilioProvider = createProvider(); const settingsService = createSettingsService(); const notificationMessageBuilder = createMessageBuilder(); @@ -62,6 +63,7 @@ const createService = (overrides?: Record) => { teamsProvider, telegramProvider, pushoverProvider, + twilioProvider, settingsService, notificationMessageBuilder, ...overrides, @@ -79,6 +81,7 @@ const createService = (overrides?: Record) => { defaults.teamsProvider as any, defaults.telegramProvider as any, defaults.pushoverProvider as any, + defaults.twilioProvider as any, defaults.settingsService as any, defaults.logger as any, defaults.notificationMessageBuilder as any From d5d6797f91e6c0c2543b1ee45fd6b5f3ca9ada46 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Fri, 17 Apr 2026 23:11:11 +0300 Subject: [PATCH 2/7] fix: use dedicated Twilio fields and add service switch case tests - Added `accountSid` and `twilioPhoneNumber` fields to Notification type, MongoDB model, and frontend type instead of reusing `address` and `homeserverUrl` - Updated provider, validation, form hook, and form UI to use new fields - Added Pushover and Twilio to notificationsService routing tests (both handleNotifications and sendTestNotification) - All 59 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/Hooks/useNotificationForm.ts | 4 ++-- client/src/Pages/Notifications/create/index.tsx | 10 ++++++---- client/src/Types/Notification.ts | 2 ++ client/src/Validation/notifications.ts | 4 ++-- server/src/db/models/Notification.ts | 2 ++ .../notificationProviders/twilio.ts | 16 ++++++++-------- server/src/types/notification.ts | 2 ++ server/src/validation/notificationValidation.ts | 4 ++-- .../notifications/twilioProvider.test.ts | 15 ++++++++------- .../unit/services/notificationsService.test.ts | 8 ++++++-- 10 files changed, 40 insertions(+), 27 deletions(-) diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 4a1e05f2c..d08f17548 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -64,10 +64,10 @@ function buildDefaults(data: Notification | null): NotificationFormData { return { type: "twilio", notificationName: data.notificationName || "", - address: data.address || "", + accountSid: data.accountSid || "", accessToken: data.accessToken || "", phone: data.phone || "", - homeserverUrl: data.homeserverUrl || "", + twilioPhoneNumber: data.twilioPhoneNumber || "", }; } if (data?.type === "pushover") { diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 3d7f85acd..0f6d9b652 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -270,9 +270,9 @@ const NotificationsCreatePage = () => { rightContent={ ( { )} /> ( ( homeserverUrl: { type: String }, roomId: { type: String }, accessToken: { type: String }, + accountSid: { type: String }, + twilioPhoneNumber: { type: String }, }, { timestamps: true, diff --git a/server/src/service/infrastructure/notificationProviders/twilio.ts b/server/src/service/infrastructure/notificationProviders/twilio.ts index 6d84f80f3..a871fe5cb 100644 --- a/server/src/service/infrastructure/notificationProviders/twilio.ts +++ b/server/src/service/infrastructure/notificationProviders/twilio.ts @@ -7,18 +7,18 @@ import got from "got"; export class TwilioProvider extends NotificationProvider { async sendTestAlert(notification: Partial): Promise { - if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) { return false; } try { - await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, { form: { To: notification.phone, - From: notification.homeserverUrl, + From: notification.twilioPhoneNumber, Body: getTestMessage(), }, - username: notification.address, + username: notification.accountSid, password: notification.accessToken, ...this.gotRequestOptions(), }); @@ -38,20 +38,20 @@ export class TwilioProvider extends NotificationProvider { } async sendMessage(notification: Notification, message: NotificationMessage): Promise { - if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) { return false; } const text = this.buildSmsText(message); try { - await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, { form: { To: notification.phone, - From: notification.homeserverUrl, + From: notification.twilioPhoneNumber, Body: text, }, - username: notification.address, + username: notification.accountSid, password: notification.accessToken, ...this.gotRequestOptions(), }); diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index f306e2592..9d5b914c0 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -23,6 +23,8 @@ export interface Notification { homeserverUrl?: string; roomId?: string; accessToken?: string; + accountSid?: string; + twilioPhoneNumber?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 545335dad..cf7478610 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -83,10 +83,10 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("twilio"), - address: z.string().min(1, "Account SID is required"), + accountSid: z.string().min(1, "Account SID is required"), accessToken: z.string().min(1, "Auth token is required"), phone: z.string().min(1, "Recipient phone number is required"), - homeserverUrl: z.string().min(1, "Twilio phone number is required"), + twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), ]); diff --git a/server/test/unit/providers/notifications/twilioProvider.test.ts b/server/test/unit/providers/notifications/twilioProvider.test.ts index 6156b648f..88bfcdf7e 100644 --- a/server/test/unit/providers/notifications/twilioProvider.test.ts +++ b/server/test/unit/providers/notifications/twilioProvider.test.ts @@ -16,7 +16,8 @@ const createProvider = () => { const makeTwilioNotification = (overrides?: Record) => makeNotification({ phone: "+15559876543", - homeserverUrl: "+15551234567", + twilioPhoneNumber: "+15551234567", + accountSid: "ACtest123", ...overrides, }); @@ -44,8 +45,8 @@ describe("TwilioProvider", () => { ); }); - it("returns false when address is missing", async () => { - expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ address: "" }))).toBe(false); + it("returns false when accountSid is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accountSid: "" }))).toBe(false); }); it("returns false when accessToken is missing", async () => { @@ -56,8 +57,8 @@ describe("TwilioProvider", () => { expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ phone: "" }))).toBe(false); }); - it("returns false when homeserverUrl (from number) is missing", async () => { - expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ homeserverUrl: "" }))).toBe(false); + it("returns false when twilioPhoneNumber (from number) is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ twilioPhoneNumber: "" }))).toBe(false); }); it("returns false and logs on error", async () => { @@ -85,8 +86,8 @@ describe("TwilioProvider", () => { expect(form.From).toBe("+15551234567"); }); - it("returns false when address is missing", async () => { - expect(await createProvider().provider.sendMessage(makeTwilioNotification({ address: "" }) as any, makeMessage())).toBe(false); + it("returns false when accountSid is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accountSid: "" }) as any, makeMessage())).toBe(false); }); it("returns false when accessToken is missing", async () => { diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index d8ee4b05c..cdc1ad320 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -147,7 +147,7 @@ describe("NotificationsService", () => { }); it("routes to correct provider for each notification type", async () => { - const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram"] as const; + const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const; for (const type of types) { const deps = createService(); (deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]); @@ -163,6 +163,8 @@ describe("NotificationsService", () => { email: deps.emailProvider, teams: deps.teamsProvider, telegram: deps.telegramProvider, + pushover: deps.pushoverProvider, + twilio: deps.twilioProvider, }; expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1); } @@ -236,7 +238,7 @@ describe("NotificationsService", () => { // ── sendTestNotification ───────────────────────────────────────────────── describe("sendTestNotification", () => { - it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"]] as const)( + it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)( "routes %s to the correct provider", async (type) => { const deps = createService(); @@ -254,6 +256,8 @@ describe("NotificationsService", () => { email: deps.emailProvider, teams: deps.teamsProvider, telegram: deps.telegramProvider, + pushover: deps.pushoverProvider, + twilio: deps.twilioProvider, }; expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification); } From c57614e8df8f536ddb0b551dad221679bdc88f4b Mon Sep 17 00:00:00 2001 From: egeoztass Date: Fri, 17 Apr 2026 23:14:16 +0300 Subject: [PATCH 3/7] fix: add accountSid and twilioPhoneNumber to notification repositories Updated both MongoDB and TimescaleDB repositories to map the new Twilio-specific fields (accountSid, twilioPhoneNumber) in create, update, and toEntity methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../notifications/MongoNotificationsRepository.ts | 2 ++ .../TimescaleNotificationsRepository.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 3c4225b50..5303aa6ab 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -32,6 +32,8 @@ class MongoNotificationsRepository implements INotificationsRepository { homeserverUrl: doc.homeserverUrl ?? undefined, roomId: doc.roomId ?? undefined, accessToken: doc.accessToken ?? undefined, + accountSid: doc.accountSid ?? undefined, + twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts index b2c369c86..b266f33f0 100644 --- a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts +++ b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts @@ -14,20 +14,22 @@ interface NotificationRow { homeserver_url: string | null; room_id: string | null; access_token: string | null; + account_sid: string | null; + twilio_phone_number: string | null; created_at: Date; updated_at: Date; } const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone, - homeserver_url, room_id, access_token, created_at, updated_at`; + homeserver_url, room_id, access_token, account_sid, twilio_phone_number, created_at, updated_at`; export class TimescaleNotificationsRepository implements INotificationsRepository { constructor(private pool: Pool) {} create = async (data: Partial): Promise => { const result = await this.pool.query( - `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token, account_sid, twilio_phone_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING ${COLUMNS}`, [ data.userId, @@ -39,6 +41,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor data.homeserverUrl ?? null, data.roomId ?? null, data.accessToken ?? null, + data.accountSid ?? null, + data.twilioPhoneNumber ?? null, ] ); const row = result.rows[0]; @@ -83,6 +87,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor ["homeserverUrl", "homeserver_url"], ["roomId", "room_id"], ["accessToken", "access_token"], + ["accountSid", "account_sid"], + ["twilioPhoneNumber", "twilio_phone_number"], ]; for (const [key, column] of fieldMap) { @@ -134,6 +140,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor homeserverUrl: row.homeserver_url ?? undefined, roomId: row.room_id ?? undefined, accessToken: row.access_token ?? undefined, + accountSid: row.account_sid ?? undefined, + twilioPhoneNumber: row.twilio_phone_number ?? undefined, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), }); From cc32206547adfab051282a72f7b3de24787128fe Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 20 Apr 2026 11:00:49 -0700 Subject: [PATCH 4/7] purge timescaledb --- server/src/config/services.ts | 34 +- server/src/db/TimescaleDB.ts | 105 -- .../timescaledb/0001_create_enums.ts | 50 - .../timescaledb/0002_create_teams.ts | 18 - .../timescaledb/0003_create_users.ts | 30 - .../timescaledb/0004_create_monitors.ts | 69 -- .../timescaledb/0005_create_notifications.ts | 30 - .../0006_create_monitor_notifications.ts | 18 - .../timescaledb/0007_create_incidents.ts | 34 - .../timescaledb/0008_create_monitor_stats.ts | 25 - .../timescaledb/0009_create_checks.ts | 105 -- .../timescaledb/0010_create_check_disks.ts | 37 - .../0011_create_check_network_interfaces.ts | 34 - .../timescaledb/0012_create_check_errors.ts | 25 - .../timescaledb/0013_create_geo_checks.ts | 29 - .../0014_create_geo_check_results.ts | 46 - .../timescaledb/0015_create_invites.ts | 22 - .../0016_create_recovery_tokens.ts | 20 - .../0017_create_maintenance_windows.ts | 29 - .../timescaledb/0018_create_status_pages.ts | 51 - .../timescaledb/0019_create_app_settings.ts | 62 - .../0020_create_continuous_aggregates.ts | 127 -- .../0021_create_retention_compression.ts | 135 -- server/src/db/migration/timescaledb/index.ts | 148 --- .../checks/TimescaleChecksRepository.ts | 1012 --------------- .../TimescaleGeoChecksRepository.ts | 274 ----- .../incidents/TimescaleIncidentsRepository.ts | 294 ----- server/src/repositories/index.ts | 13 - .../invites/TimescaleInvitesRepository.ts | 77 -- .../TimescaleMaintenanceWindowsRepository.ts | 197 --- .../TimescaleMonitorStatsRepository.ts | 163 --- .../monitors/TimescaleMonitorsRepository.ts | 1093 ----------------- .../TimescaleNotificationsRepository.ts | 148 --- .../TimescaleRecoveryTokensRepository.ts | 59 - .../settings/TimescaleSettingsRepository.ts | 217 ---- .../TimescaleStatusPagesRepository.ts | 260 ---- .../teams/TimescaleTeamsRepository.ts | 24 - .../users/TimescaleUsersRepository.ts | 222 ---- server/src/types/settings.ts | 2 +- 39 files changed, 2 insertions(+), 5336 deletions(-) delete mode 100644 server/src/db/TimescaleDB.ts delete mode 100644 server/src/db/migration/timescaledb/0001_create_enums.ts delete mode 100644 server/src/db/migration/timescaledb/0002_create_teams.ts delete mode 100644 server/src/db/migration/timescaledb/0003_create_users.ts delete mode 100644 server/src/db/migration/timescaledb/0004_create_monitors.ts delete mode 100644 server/src/db/migration/timescaledb/0005_create_notifications.ts delete mode 100644 server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts delete mode 100644 server/src/db/migration/timescaledb/0007_create_incidents.ts delete mode 100644 server/src/db/migration/timescaledb/0008_create_monitor_stats.ts delete mode 100644 server/src/db/migration/timescaledb/0009_create_checks.ts delete mode 100644 server/src/db/migration/timescaledb/0010_create_check_disks.ts delete mode 100644 server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts delete mode 100644 server/src/db/migration/timescaledb/0012_create_check_errors.ts delete mode 100644 server/src/db/migration/timescaledb/0013_create_geo_checks.ts delete mode 100644 server/src/db/migration/timescaledb/0014_create_geo_check_results.ts delete mode 100644 server/src/db/migration/timescaledb/0015_create_invites.ts delete mode 100644 server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts delete mode 100644 server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts delete mode 100644 server/src/db/migration/timescaledb/0018_create_status_pages.ts delete mode 100644 server/src/db/migration/timescaledb/0019_create_app_settings.ts delete mode 100644 server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts delete mode 100644 server/src/db/migration/timescaledb/0021_create_retention_compression.ts delete mode 100644 server/src/db/migration/timescaledb/index.ts delete mode 100644 server/src/repositories/checks/TimescaleChecksRepository.ts delete mode 100644 server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts delete mode 100644 server/src/repositories/incidents/TimescaleIncidentsRepository.ts delete mode 100644 server/src/repositories/invites/TimescaleInvitesRepository.ts delete mode 100644 server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts delete mode 100644 server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts delete mode 100644 server/src/repositories/monitors/TimescaleMonitorsRepository.ts delete mode 100644 server/src/repositories/notifications/TimescaleNotificationsRepository.ts delete mode 100644 server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts delete mode 100644 server/src/repositories/settings/TimescaleSettingsRepository.ts delete mode 100644 server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts delete mode 100644 server/src/repositories/teams/TimescaleTeamsRepository.ts delete mode 100644 server/src/repositories/users/TimescaleUsersRepository.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 8a5db6b49..c7e1a4469 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -100,19 +100,6 @@ import { MongoTeamsRepository, MongoMaintenanceWindowsRepository, MongoSettingsRepository, - TimescaleMonitorsRepository, - TimescaleChecksRepository, - TimescaleGeoChecksRepository, - TimescaleMonitorStatsRepository, - TimescaleStatusPagesRepository, - TimescaleUsersRepository, - TimescaleInvitesRepository, - TimescaleRecoveryTokensRepository, - TimescaleNotificationsRepository, - TimescaleIncidentsRepository, - TimescaleTeamsRepository, - TimescaleMaintenanceWindowsRepository, - TimescaleSettingsRepository, IMonitorsRepository, IChecksRepository, IGeoChecksRepository, @@ -128,7 +115,6 @@ import { IMaintenanceWindowsRepository, } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; -import TimescaleDB from "@/db/TimescaleDB.js"; import { AppError } from "@/utils/AppError.js"; export type InitializedServices = { @@ -185,8 +171,6 @@ export const initializeServices = async ({ if (dbType === "mongodb") { db = new MongoDB(logger, envSettings); - } else if (dbType === "timescaledb") { - db = new TimescaleDB(logger, envSettings); } if (!db) { @@ -226,23 +210,7 @@ export const initializeServices = async ({ teamsRepository = new MongoTeamsRepository(); maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); } else { - const pool = db.getPool(); - if (!pool) { - throw new Error("Failed to get database pool"); - } - monitorsRepository = new TimescaleMonitorsRepository(pool); - checksRepository = new TimescaleChecksRepository(pool); - geoChecksRepository = new TimescaleGeoChecksRepository(pool); - monitorStatsRepository = new TimescaleMonitorStatsRepository(pool); - statusPagesRepository = new TimescaleStatusPagesRepository(pool); - usersRepository = new TimescaleUsersRepository(pool); - invitesRepository = new TimescaleInvitesRepository(pool); - recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool); - settingsRepository = new TimescaleSettingsRepository(pool); - notificationsRepository = new TimescaleNotificationsRepository(pool); - incidentsRepository = new TimescaleIncidentsRepository(pool); - teamsRepository = new TimescaleTeamsRepository(pool); - maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool); + throw new AppError({ message: "Unsupported database type", status: 500 }); } // Inject settings repository into settings service (now that DB is connected) diff --git a/server/src/db/TimescaleDB.ts b/server/src/db/TimescaleDB.ts deleted file mode 100644 index b36c54292..000000000 --- a/server/src/db/TimescaleDB.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { IDb } from "@/db/IDb.js"; -import { ILogger } from "@/utils/logger.js"; -import { EnvConfig } from "@/service/system/settingsService.js"; -import { Client, Pool } from "pg"; -import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js"; - -const SERVICE_NAME = "TimescaleDB"; - -class TimescaleDB implements IDb { - private pool: Pool | null = null; - - constructor( - private logger: ILogger, - private envSettings: EnvConfig - ) {} - - private ensureDatabaseExists = async (connectionString: string) => { - const url = new URL(connectionString); - const dbName = url.pathname.slice(1); // Remove leading "/" - - // Connect to the default "postgres" database - url.pathname = "/postgres"; - const client = new Client({ connectionString: url.toString() }); - await client.connect(); - - const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]); - if (result.rowCount === 0) { - await client.query(`CREATE DATABASE "${dbName}"`); - this.logger.info({ - message: `Created database "${dbName}"`, - service: SERVICE_NAME, - method: "ensureDatabaseExists", - }); - } - await client.end(); - }; - - connect = async () => { - try { - const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate"; - - await this.ensureDatabaseExists(connectionString); - - this.pool = new Pool({ connectionString }); - - const client = await this.pool.connect(); - client.release(); - - // Ensure TimescaleDB extension is enabled - await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;"); - - this.logger.info({ - message: "Connected to TimescaleDB", - service: SERVICE_NAME, - method: "connect", - }); - - await runTimescaleDBMigrations(this.pool, this.logger); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "connect", - stack: error instanceof Error ? error.stack : undefined, - }); - throw error; - } - }; - - disconnect = async () => { - try { - if (this.pool === null) { - return; - } - this.logger.info({ - message: "Disconnecting from TimescaleDB", - service: SERVICE_NAME, - method: "disconnect", - }); - await this.pool.end(); - this.pool = null; - this.logger.info({ - message: "Disconnected from TimescaleDB", - service: SERVICE_NAME, - method: "disconnect", - }); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "disconnect", - stack: error instanceof Error ? error.stack : undefined, - }); - } - }; - - getPool = (): Pool => { - if (this.pool === null) { - throw new Error("TimescaleDB is not connected. Call connect() first."); - } - return this.pool; - }; -} - -export default TimescaleDB; diff --git a/server/src/db/migration/timescaledb/0001_create_enums.ts b/server/src/db/migration/timescaledb/0001_create_enums.ts deleted file mode 100644 index a16ec955b..000000000 --- a/server/src/db/migration/timescaledb/0001_create_enums.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Pool } from "pg"; - -export const createEnums = async (pool: Pool) => { - await pool.query(` - DO $$ BEGIN - CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - `); -}; - -export const dropEnums = async (pool: Pool) => { - await pool.query(` - DROP TYPE IF EXISTS match_method; - DROP TYPE IF EXISTS status_page_type; - DROP TYPE IF EXISTS duration_unit; - DROP TYPE IF EXISTS incident_resolution_type; - DROP TYPE IF EXISTS user_role; - DROP TYPE IF EXISTS notification_type; - DROP TYPE IF EXISTS monitor_status; - DROP TYPE IF EXISTS monitor_type; - `); -}; diff --git a/server/src/db/migration/timescaledb/0002_create_teams.ts b/server/src/db/migration/timescaledb/0002_create_teams.ts deleted file mode 100644 index 905883eb2..000000000 --- a/server/src/db/migration/timescaledb/0002_create_teams.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Pool } from "pg"; - -export const createTeams = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS teams ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email); - `); -}; - -export const dropTeams = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS teams;`); -}; diff --git a/server/src/db/migration/timescaledb/0003_create_users.ts b/server/src/db/migration/timescaledb/0003_create_users.ts deleted file mode 100644 index 6c8f57027..000000000 --- a/server/src/db/migration/timescaledb/0003_create_users.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Pool } from "pg"; - -export const createUsers = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - team_id UUID REFERENCES teams(id) ON DELETE SET NULL, - first_name TEXT NOT NULL, - last_name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - avatar_image TEXT, - profile_image BYTEA, - profile_image_content_type TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - is_verified BOOLEAN NOT NULL DEFAULT FALSE, - roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[], - check_ttl INTEGER, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id); - CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); - `); -}; - -export const dropUsers = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS users;`); -}; diff --git a/server/src/db/migration/timescaledb/0004_create_monitors.ts b/server/src/db/migration/timescaledb/0004_create_monitors.ts deleted file mode 100644 index a4829c7f5..000000000 --- a/server/src/db/migration/timescaledb/0004_create_monitors.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Pool } from "pg"; - -export const createMonitors = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS monitors ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - team_id UUID NOT NULL REFERENCES teams(id), - name TEXT NOT NULL, - description TEXT, - type monitor_type NOT NULL, - status monitor_status NOT NULL DEFAULT 'initializing', - - -- HTTP / Website monitoring - url TEXT, - port INTEGER, - ignore_tls_errors BOOLEAN DEFAULT FALSE, - use_advanced_matching BOOLEAN DEFAULT FALSE, - json_path TEXT, - expected_value TEXT, - match_method match_method, - secret TEXT, - - -- Scheduling - interval_ms INTEGER NOT NULL DEFAULT 60000, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Uptime tracking - status_window BOOLEAN[], - status_window_size INTEGER DEFAULT 5, - status_window_threshold INTEGER DEFAULT 60, - uptime_percentage DOUBLE PRECISION, - - -- Infrastructure alert thresholds - cpu_alert_threshold INTEGER, - cpu_alert_counter INTEGER, - memory_alert_threshold INTEGER, - memory_alert_counter INTEGER, - disk_alert_threshold INTEGER, - disk_alert_counter INTEGER, - temp_alert_threshold INTEGER, - temp_alert_counter INTEGER, - selected_disks TEXT[], - - -- Game monitoring - game_id TEXT, - grpc_service_name TEXT, - - -- Grouping - monitor_group TEXT, - - -- Geo checks - geo_check_enabled BOOLEAN DEFAULT FALSE, - geo_check_locations TEXT[], - geo_check_interval_ms INTEGER DEFAULT 300000, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type); - CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id); - CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status); - `); -}; - -export const dropMonitors = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS monitors;`); -}; diff --git a/server/src/db/migration/timescaledb/0005_create_notifications.ts b/server/src/db/migration/timescaledb/0005_create_notifications.ts deleted file mode 100644 index 2a4a065fa..000000000 --- a/server/src/db/migration/timescaledb/0005_create_notifications.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Pool } from "pg"; - -export const createNotifications = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - team_id UUID NOT NULL REFERENCES teams(id), - type notification_type NOT NULL, - notification_name TEXT, - address TEXT, - phone TEXT, - - -- Matrix-specific fields - homeserver_url TEXT, - room_id TEXT, - access_token TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id); - CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id); - `); -}; - -export const dropNotifications = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS notifications;`); -}; diff --git a/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts b/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts deleted file mode 100644 index d2a306322..000000000 --- a/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Pool } from "pg"; - -export const createMonitorNotifications = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS monitor_notifications ( - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE, - PRIMARY KEY (monitor_id, notification_id) - ); - - CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification - ON monitor_notifications (notification_id); - `); -}; - -export const dropMonitorNotifications = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`); -}; diff --git a/server/src/db/migration/timescaledb/0007_create_incidents.ts b/server/src/db/migration/timescaledb/0007_create_incidents.ts deleted file mode 100644 index e1c990f95..000000000 --- a/server/src/db/migration/timescaledb/0007_create_incidents.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Pool } from "pg"; - -export const createIncidents = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS incidents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - start_time TIMESTAMPTZ NOT NULL, - end_time TIMESTAMPTZ, - status BOOLEAN, - message TEXT, - status_code INTEGER, - resolution_type incident_resolution_type, - resolved_by UUID REFERENCES users(id) ON DELETE SET NULL, - resolved_by_email TEXT, - comment TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status); - CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status); - CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC); - CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC); - CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status); - CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status); - CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC); - `); -}; - -export const dropIncidents = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS incidents;`); -}; diff --git a/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts b/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts deleted file mode 100644 index f08004873..000000000 --- a/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Pool } from "pg"; - -export const createMonitorStats = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS monitor_stats ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE, - avg_response_time DOUBLE PRECISION DEFAULT 0, - max_response_time DOUBLE PRECISION DEFAULT 0, - total_checks BIGINT DEFAULT 0, - total_up_checks BIGINT DEFAULT 0, - total_down_checks BIGINT DEFAULT 0, - uptime_percentage DOUBLE PRECISION DEFAULT 0, - last_check_timestamp TIMESTAMPTZ, - last_response_time DOUBLE PRECISION DEFAULT 0, - time_of_last_failure TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - `); -}; - -export const dropMonitorStats = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS monitor_stats;`); -}; diff --git a/server/src/db/migration/timescaledb/0009_create_checks.ts b/server/src/db/migration/timescaledb/0009_create_checks.ts deleted file mode 100644 index 27753f524..000000000 --- a/server/src/db/migration/timescaledb/0009_create_checks.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Pool } from "pg"; - -export const createChecks = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS checks ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL, - team_id UUID NOT NULL, - monitor_type monitor_type NOT NULL, - status BOOLEAN, - response_time INTEGER, - status_code INTEGER, - message TEXT, - - -- HTTP request timings - timing_start DOUBLE PRECISION, - timing_socket DOUBLE PRECISION, - timing_lookup DOUBLE PRECISION, - timing_connect DOUBLE PRECISION, - timing_secure_connect DOUBLE PRECISION, - timing_upload DOUBLE PRECISION, - timing_response DOUBLE PRECISION, - timing_end DOUBLE PRECISION, - phase_wait DOUBLE PRECISION, - phase_dns DOUBLE PRECISION, - phase_tcp DOUBLE PRECISION, - phase_tls DOUBLE PRECISION, - phase_request DOUBLE PRECISION, - phase_first_byte DOUBLE PRECISION, - phase_download DOUBLE PRECISION, - phase_total DOUBLE PRECISION, - - -- CPU metrics - cpu_physical_core INTEGER, - cpu_logical_core INTEGER, - cpu_frequency DOUBLE PRECISION, - cpu_current_frequency DOUBLE PRECISION, - cpu_temperature DOUBLE PRECISION[], - cpu_free_percent DOUBLE PRECISION, - cpu_usage_percent DOUBLE PRECISION, - - -- Memory metrics - mem_total_bytes BIGINT, - mem_available_bytes BIGINT, - mem_used_bytes BIGINT, - mem_usage_percent DOUBLE PRECISION, - - -- Host info - host_os TEXT, - host_platform TEXT, - host_kernel_version TEXT, - host_pretty_name TEXT, - - -- Capture agent info - capture_version TEXT, - capture_mode TEXT, - - -- Lighthouse / PageSpeed scores - lighthouse_performance DOUBLE PRECISION, - lighthouse_accessibility DOUBLE PRECISION, - lighthouse_best_practices DOUBLE PRECISION, - lighthouse_seo DOUBLE PRECISION, - - -- Lighthouse audit details - audit_cls_score DOUBLE PRECISION, - audit_cls_value DOUBLE PRECISION, - audit_cls_display TEXT, - audit_si_score DOUBLE PRECISION, - audit_si_value DOUBLE PRECISION, - audit_si_display TEXT, - audit_fcp_score DOUBLE PRECISION, - audit_fcp_value DOUBLE PRECISION, - audit_fcp_display TEXT, - audit_lcp_score DOUBLE PRECISION, - audit_lcp_value DOUBLE PRECISION, - audit_lcp_display TEXT, - audit_tbt_score DOUBLE PRECISION, - audit_tbt_value DOUBLE PRECISION, - audit_tbt_display TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - SELECT create_hypertable('checks', 'created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_checks_monitor_time - ON checks (monitor_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_checks_team_time - ON checks (team_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time - ON checks (monitor_id, monitor_type, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_checks_team_status_time - ON checks (team_id, status, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_checks_status_code - ON checks (status_code, created_at DESC); - `); -}; - -export const dropChecks = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS checks;`); -}; diff --git a/server/src/db/migration/timescaledb/0010_create_check_disks.ts b/server/src/db/migration/timescaledb/0010_create_check_disks.ts deleted file mode 100644 index e77784d39..000000000 --- a/server/src/db/migration/timescaledb/0010_create_check_disks.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Pool } from "pg"; - -export const createCheckDisks = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS check_disks ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - check_id UUID NOT NULL, - check_created_at TIMESTAMPTZ NOT NULL, - device TEXT, - mountpoint TEXT, - total_bytes BIGINT, - free_bytes BIGINT, - used_bytes BIGINT, - usage_percent DOUBLE PRECISION, - total_inodes BIGINT, - free_inodes BIGINT, - used_inodes BIGINT, - inodes_usage_percent DOUBLE PRECISION, - read_bytes BIGINT, - write_bytes BIGINT, - read_time BIGINT, - write_time BIGINT - ); - - SELECT create_hypertable('check_disks', 'check_created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_check_disks_check - ON check_disks (check_id, check_created_at); - `); -}; - -export const dropCheckDisks = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS check_disks;`); -}; diff --git a/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts b/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts deleted file mode 100644 index 4988fe58e..000000000 --- a/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Pool } from "pg"; - -export const createCheckNetworkInterfaces = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS check_network_interfaces ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - check_id UUID NOT NULL, - check_created_at TIMESTAMPTZ NOT NULL, - name TEXT, - bytes_sent BIGINT, - bytes_recv BIGINT, - packets_sent BIGINT, - packets_recv BIGINT, - err_in BIGINT, - err_out BIGINT, - drop_in BIGINT, - drop_out BIGINT, - fifo_in BIGINT, - fifo_out BIGINT - ); - - SELECT create_hypertable('check_network_interfaces', 'check_created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_check_net_check - ON check_network_interfaces (check_id, check_created_at); - `); -}; - -export const dropCheckNetworkInterfaces = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`); -}; diff --git a/server/src/db/migration/timescaledb/0012_create_check_errors.ts b/server/src/db/migration/timescaledb/0012_create_check_errors.ts deleted file mode 100644 index c84bacac8..000000000 --- a/server/src/db/migration/timescaledb/0012_create_check_errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Pool } from "pg"; - -export const createCheckErrors = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS check_errors ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - check_id UUID NOT NULL, - check_created_at TIMESTAMPTZ NOT NULL, - metrics TEXT[], - error TEXT - ); - - SELECT create_hypertable('check_errors', 'check_created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_check_errors_check - ON check_errors (check_id, check_created_at); - `); -}; - -export const dropCheckErrors = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS check_errors;`); -}; diff --git a/server/src/db/migration/timescaledb/0013_create_geo_checks.ts b/server/src/db/migration/timescaledb/0013_create_geo_checks.ts deleted file mode 100644 index a6ee03061..000000000 --- a/server/src/db/migration/timescaledb/0013_create_geo_checks.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Pool } from "pg"; - -export const createGeoChecks = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS geo_checks ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL, - team_id UUID NOT NULL, - monitor_type monitor_type NOT NULL, - expiry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - SELECT create_hypertable('geo_checks', 'created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time - ON geo_checks (monitor_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time - ON geo_checks (team_id, created_at DESC); - `); -}; - -export const dropGeoChecks = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS geo_checks;`); -}; diff --git a/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts b/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts deleted file mode 100644 index 4e0e45e1a..000000000 --- a/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Pool } from "pg"; - -export const createGeoCheckResults = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS geo_check_results ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - geo_check_id UUID NOT NULL, - geo_check_created_at TIMESTAMPTZ NOT NULL, - - -- Status - status BOOLEAN, - status_code INTEGER, - - -- Location - location_continent TEXT, - location_region TEXT, - location_country TEXT, - location_state TEXT, - location_city TEXT, - location_longitude DOUBLE PRECISION, - location_latitude DOUBLE PRECISION, - - -- Timings - timing_total DOUBLE PRECISION, - timing_dns DOUBLE PRECISION, - timing_tcp DOUBLE PRECISION, - timing_tls DOUBLE PRECISION, - timing_first_byte DOUBLE PRECISION, - timing_download DOUBLE PRECISION - ); - - SELECT create_hypertable('geo_check_results', 'geo_check_created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_geo_results_check - ON geo_check_results (geo_check_id, geo_check_created_at); - CREATE INDEX IF NOT EXISTS idx_geo_results_country - ON geo_check_results (location_country, geo_check_created_at DESC); - `); -}; - -export const dropGeoCheckResults = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS geo_check_results;`); -}; diff --git a/server/src/db/migration/timescaledb/0015_create_invites.ts b/server/src/db/migration/timescaledb/0015_create_invites.ts deleted file mode 100644 index 80aca5255..000000000 --- a/server/src/db/migration/timescaledb/0015_create_invites.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Pool } from "pg"; - -export const createInvites = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS invites ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[], - token TEXT NOT NULL, - expiry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry); - `); -}; - -export const dropInvites = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS invites;`); -}; diff --git a/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts b/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts deleted file mode 100644 index 36a9d0de3..000000000 --- a/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Pool } from "pg"; - -export const createRecoveryTokens = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS recovery_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - token TEXT NOT NULL, - expiry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry); - `); -}; - -export const dropRecoveryTokens = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`); -}; diff --git a/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts b/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts deleted file mode 100644 index 190769261..000000000 --- a/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Pool } from "pg"; - -export const createMaintenanceWindows = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS maintenance_windows ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - active BOOLEAN NOT NULL DEFAULT TRUE, - name TEXT, - duration INTEGER, - duration_unit duration_unit, - repeat INTEGER, - start_time TIMESTAMPTZ, - end_time TIMESTAMPTZ, - expiry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id); - CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id); - CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry); - `); -}; - -export const dropMaintenanceWindows = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`); -}; diff --git a/server/src/db/migration/timescaledb/0018_create_status_pages.ts b/server/src/db/migration/timescaledb/0018_create_status_pages.ts deleted file mode 100644 index 6037f6852..000000000 --- a/server/src/db/migration/timescaledb/0018_create_status_pages.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Pool } from "pg"; - -export const createStatusPages = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS status_pages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - team_id UUID NOT NULL REFERENCES teams(id), - types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[], - company_name TEXT, - url TEXT NOT NULL UNIQUE, - timezone TEXT, - color TEXT DEFAULT '#4169E1', - logo_data BYTEA, - logo_content_type TEXT, - is_published BOOLEAN DEFAULT FALSE, - show_charts BOOLEAN DEFAULT FALSE, - show_uptime_percentage BOOLEAN DEFAULT FALSE, - show_admin_login_link BOOLEAN DEFAULT FALSE, - show_infrastructure BOOLEAN DEFAULT FALSE, - custom_css TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id); - CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url); - - CREATE TABLE IF NOT EXISTS status_page_monitors ( - status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - sort_order INTEGER DEFAULT 0, - PRIMARY KEY (status_page_id, monitor_id) - ); - - CREATE TABLE IF NOT EXISTS status_page_sub_monitors ( - status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - sort_order INTEGER DEFAULT 0, - PRIMARY KEY (status_page_id, monitor_id) - ); - `); -}; - -export const dropStatusPages = async (pool: Pool) => { - await pool.query(` - DROP TABLE IF EXISTS status_page_sub_monitors; - DROP TABLE IF EXISTS status_page_monitors; - DROP TABLE IF EXISTS status_pages; - `); -}; diff --git a/server/src/db/migration/timescaledb/0019_create_app_settings.ts b/server/src/db/migration/timescaledb/0019_create_app_settings.ts deleted file mode 100644 index d2d624d07..000000000 --- a/server/src/db/migration/timescaledb/0019_create_app_settings.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Pool } from "pg"; - -export const createAppSettings = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS app_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - check_ttl INTEGER DEFAULT 30, - language TEXT, - jwt_secret TEXT, - pagespeed_api_key TEXT, - - -- SMTP / Email settings - system_email_host TEXT, - system_email_port INTEGER, - system_email_address TEXT, - system_email_password TEXT, - system_email_user TEXT, - system_email_connection_host TEXT, - system_email_tls_servername TEXT, - system_email_secure BOOLEAN, - system_email_pool BOOLEAN, - system_email_ignore_tls BOOLEAN, - system_email_require_tls BOOLEAN, - system_email_reject_unauthorized BOOLEAN, - - show_url BOOLEAN, - version TEXT, - - -- Global infrastructure thresholds - threshold_cpu_usage INTEGER, - threshold_memory_usage INTEGER, - threshold_disk_usage INTEGER, - threshold_temperature INTEGER, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - -- Enforce singleton: only one row allowed - CREATE OR REPLACE FUNCTION enforce_singleton_app_settings() - RETURNS TRIGGER AS $$ - BEGIN - IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN - RAISE EXCEPTION 'app_settings allows only one row'; - END IF; - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings; - CREATE TRIGGER trg_singleton_app_settings - BEFORE INSERT ON app_settings - FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings(); - `); -}; - -export const dropAppSettings = async (pool: Pool) => { - await pool.query(` - DROP TABLE IF EXISTS app_settings; - DROP FUNCTION IF EXISTS enforce_singleton_app_settings(); - `); -}; diff --git a/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts b/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts deleted file mode 100644 index d435ffdf9..000000000 --- a/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Pool } from "pg"; - -export const createContinuousAggregates = async (pool: Pool) => { - // Uptime & Response Time (Hourly) - await pool.query(` - CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly - WITH (timescaledb.continuous) AS - SELECT - monitor_id, - time_bucket('1 hour', created_at) AS bucket, - COUNT(*) AS total_checks, - COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, - COUNT(*) FILTER (WHERE status = FALSE) AS down_checks, - AVG(response_time) AS avg_response_time, - AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time, - AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time, - MAX(response_time) AS max_response_time, - MIN(response_time) AS min_response_time - FROM checks - GROUP BY monitor_id, bucket - WITH NO DATA; - `); - await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`); - await pool.query(` - SELECT add_continuous_aggregate_policy('checks_hourly', - start_offset => INTERVAL '3 hours', - end_offset => INTERVAL '1 hour', - schedule_interval => INTERVAL '1 hour', - if_not_exists => TRUE - ); - `); - await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`); - - // Uptime & Response Time (Daily) - await pool.query(` - CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily - WITH (timescaledb.continuous) AS - SELECT - monitor_id, - time_bucket('1 day', created_at) AS bucket, - COUNT(*) AS total_checks, - COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, - COUNT(*) FILTER (WHERE status = FALSE) AS down_checks, - AVG(response_time) AS avg_response_time, - AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time, - AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time, - MAX(response_time) AS max_response_time - FROM checks - GROUP BY monitor_id, bucket - WITH NO DATA; - `); - await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`); - await pool.query(` - SELECT add_continuous_aggregate_policy('checks_daily', - start_offset => INTERVAL '3 days', - end_offset => INTERVAL '1 day', - schedule_interval => INTERVAL '1 day', - if_not_exists => TRUE - ); - `); - await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`); - - // Infrastructure Metrics (Hourly) - await pool.query(` - CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly - WITH (timescaledb.continuous) AS - SELECT - monitor_id, - time_bucket('1 hour', created_at) AS bucket, - AVG(cpu_usage_percent) AS avg_cpu, - MAX(cpu_usage_percent) AS max_cpu, - AVG(mem_usage_percent) AS avg_memory, - MAX(mem_usage_percent) AS max_memory, - COUNT(*) AS sample_count - FROM checks - WHERE monitor_type = 'hardware' - GROUP BY monitor_id, bucket - WITH NO DATA; - `); - await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`); - await pool.query(` - SELECT add_continuous_aggregate_policy('hardware_hourly', - start_offset => INTERVAL '3 hours', - end_offset => INTERVAL '1 hour', - schedule_interval => INTERVAL '1 hour', - if_not_exists => TRUE - ); - `); - await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`); - - // PageSpeed Scores (Daily) - await pool.query(` - CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily - WITH (timescaledb.continuous) AS - SELECT - monitor_id, - time_bucket('1 day', created_at) AS bucket, - AVG(lighthouse_performance) AS avg_performance, - AVG(lighthouse_accessibility) AS avg_accessibility, - AVG(lighthouse_best_practices) AS avg_best_practices, - AVG(lighthouse_seo) AS avg_seo, - COUNT(*) AS sample_count - FROM checks - WHERE monitor_type = 'pagespeed' - GROUP BY monitor_id, bucket - WITH NO DATA; - `); - await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`); - await pool.query(` - SELECT add_continuous_aggregate_policy('pagespeed_daily', - start_offset => INTERVAL '3 days', - end_offset => INTERVAL '1 day', - schedule_interval => INTERVAL '1 day', - if_not_exists => TRUE - ); - `); - await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`); -}; - -export const dropContinuousAggregates = async (pool: Pool) => { - await pool.query(` - DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE; - DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE; - DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE; - DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE; - `); -}; diff --git a/server/src/db/migration/timescaledb/0021_create_retention_compression.ts b/server/src/db/migration/timescaledb/0021_create_retention_compression.ts deleted file mode 100644 index 95230e248..000000000 --- a/server/src/db/migration/timescaledb/0021_create_retention_compression.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Pool } from "pg"; - -export const createRetentionCompression = async (pool: Pool) => { - // -- Compression policies -- - - // Checks - await pool.query(` - ALTER TABLE checks SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'monitor_id', - timescaledb.compress_orderby = 'created_at DESC' - ); - SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE); - `); - - // Check child tables - await pool.query(` - ALTER TABLE check_disks SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'check_id', - timescaledb.compress_orderby = 'check_created_at DESC' - ); - SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE); - `); - - await pool.query(` - ALTER TABLE check_network_interfaces SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'check_id', - timescaledb.compress_orderby = 'check_created_at DESC' - ); - SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE); - `); - - await pool.query(` - ALTER TABLE check_errors SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'check_id', - timescaledb.compress_orderby = 'check_created_at DESC' - ); - SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE); - `); - - // Geo checks - await pool.query(` - ALTER TABLE geo_checks SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'monitor_id', - timescaledb.compress_orderby = 'created_at DESC' - ); - SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE); - `); - - await pool.query(` - ALTER TABLE geo_check_results SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'geo_check_id', - timescaledb.compress_orderby = 'geo_check_created_at DESC' - ); - SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE); - `); - - // -- Retention policies (default 30 days) -- - - await pool.query(` - SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE); - `); - - // -- Dynamic retention helper function -- - - await pool.query(` - CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER) - RETURNS VOID AS $$ - DECLARE - ttl_interval INTERVAL; - BEGIN - ttl_interval := (new_ttl_days || ' days')::INTERVAL; - - PERFORM remove_retention_policy('checks', if_exists => true); - PERFORM remove_retention_policy('check_disks', if_exists => true); - PERFORM remove_retention_policy('check_network_interfaces', if_exists => true); - PERFORM remove_retention_policy('check_errors', if_exists => true); - PERFORM remove_retention_policy('geo_checks', if_exists => true); - PERFORM remove_retention_policy('geo_check_results', if_exists => true); - - PERFORM add_retention_policy('checks', ttl_interval); - PERFORM add_retention_policy('check_disks', ttl_interval); - PERFORM add_retention_policy('check_network_interfaces', ttl_interval); - PERFORM add_retention_policy('check_errors', ttl_interval); - PERFORM add_retention_policy('geo_checks', ttl_interval); - PERFORM add_retention_policy('geo_check_results', ttl_interval); - END; - $$ LANGUAGE plpgsql; - `); -}; - -export const dropRetentionCompression = async (pool: Pool) => { - // Remove retention policies - await pool.query(` - SELECT remove_retention_policy('checks', if_exists => true); - SELECT remove_retention_policy('check_disks', if_exists => true); - SELECT remove_retention_policy('check_network_interfaces', if_exists => true); - SELECT remove_retention_policy('check_errors', if_exists => true); - SELECT remove_retention_policy('geo_checks', if_exists => true); - SELECT remove_retention_policy('geo_check_results', if_exists => true); - `); - - // Remove compression policies - await pool.query(` - SELECT remove_compression_policy('checks', if_exists => true); - SELECT remove_compression_policy('check_disks', if_exists => true); - SELECT remove_compression_policy('check_network_interfaces', if_exists => true); - SELECT remove_compression_policy('check_errors', if_exists => true); - SELECT remove_compression_policy('geo_checks', if_exists => true); - SELECT remove_compression_policy('geo_check_results', if_exists => true); - `); - - // Disable compression on tables - await pool.query(` - ALTER TABLE checks SET (timescaledb.compress = false); - ALTER TABLE check_disks SET (timescaledb.compress = false); - ALTER TABLE check_network_interfaces SET (timescaledb.compress = false); - ALTER TABLE check_errors SET (timescaledb.compress = false); - ALTER TABLE geo_checks SET (timescaledb.compress = false); - ALTER TABLE geo_check_results SET (timescaledb.compress = false); - `); - - // Drop dynamic retention function - await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`); -}; diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts deleted file mode 100644 index 14084ebf3..000000000 --- a/server/src/db/migration/timescaledb/index.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Pool } from "pg"; -import type { ILogger } from "@/utils/logger.js"; -import { createEnums, dropEnums } from "./0001_create_enums.js"; -import { createTeams, dropTeams } from "./0002_create_teams.js"; -import { createUsers, dropUsers } from "./0003_create_users.js"; -import { createMonitors, dropMonitors } from "./0004_create_monitors.js"; -import { createNotifications, dropNotifications } from "./0005_create_notifications.js"; -import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js"; -import { createIncidents, dropIncidents } from "./0007_create_incidents.js"; -import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js"; -import { createChecks, dropChecks } from "./0009_create_checks.js"; -import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js"; -import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js"; -import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js"; -import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js"; -import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js"; -import { createInvites, dropInvites } from "./0015_create_invites.js"; -import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js"; -import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js"; -import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js"; -import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; -import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; -import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; - -const SERVICE_NAME = "TimescaleDB Migrations"; - -type MigrationEntry = { - name: string; - up: (pool: Pool) => Promise; - down: (pool: Pool) => Promise; -}; - -const migrations: MigrationEntry[] = [ - { name: "0001_create_enums", up: createEnums, down: dropEnums }, - { name: "0002_create_teams", up: createTeams, down: dropTeams }, - { name: "0003_create_users", up: createUsers, down: dropUsers }, - { name: "0004_create_monitors", up: createMonitors, down: dropMonitors }, - { name: "0005_create_notifications", up: createNotifications, down: dropNotifications }, - { name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications }, - { name: "0007_create_incidents", up: createIncidents, down: dropIncidents }, - { name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats }, - { name: "0009_create_checks", up: createChecks, down: dropChecks }, - { name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks }, - { name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces }, - { name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors }, - { name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks }, - { name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults }, - { name: "0015_create_invites", up: createInvites, down: dropInvites }, - { name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens }, - { name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows }, - { name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages }, - { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, - { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, - { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, -]; - -const ensureMigrationsTable = async (pool: Pool) => { - await pool.query(` - DO $$ BEGIN - CREATE TYPE migration_status AS ENUM ('completed', 'failed'); - EXCEPTION - WHEN duplicate_object THEN NULL; - END $$; - `); - await pool.query(` - CREATE TABLE IF NOT EXISTS migrations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - status migration_status NOT NULL, - completed_at TIMESTAMPTZ, - error TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - `); -}; - -const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => { - try { - logger?.info({ message: "Running migrations", service: SERVICE_NAME }); - - await ensureMigrationsTable(pool); - - for (const migration of migrations) { - const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]); - if (result.rowCount !== null && result.rowCount > 0) { - logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME }); - continue; - } - - try { - await migration.up(pool); - await pool.query( - `INSERT INTO migrations (name, status, completed_at) - VALUES ($1, 'completed', NOW()) - ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`, - [migration.name] - ); - logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME }); - } catch (error) { - const err = error as Error; - await pool.query( - `INSERT INTO migrations (name, status, error) - VALUES ($1, 'failed', $2) - ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`, - [migration.name, err?.message] - ); - throw error; - } - } - - logger?.info({ message: "Migrations completed", service: SERVICE_NAME }); - } catch (error) { - const err = error as Error; - logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack }); - throw error; - } -}; - -const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => { - try { - await ensureMigrationsTable(pool); - - // Find the last completed migration - const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1"); - if (result.rowCount === 0) { - logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME }); - return; - } - - const lastMigrationName = result.rows[0].name; - const migration = migrations.find((m) => m.name === lastMigrationName); - if (!migration) { - throw new Error(`Migration "${lastMigrationName}" not found in migration list`); - } - - logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME }); - await migration.down(pool); - await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]); - logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME }); - } catch (error) { - const err = error as Error; - logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack }); - throw error; - } -}; - -export { runTimescaleDBMigrations, rollbackTimescaleDBMigration }; diff --git a/server/src/repositories/checks/TimescaleChecksRepository.ts b/server/src/repositories/checks/TimescaleChecksRepository.ts deleted file mode 100644 index 466be0f71..000000000 --- a/server/src/repositories/checks/TimescaleChecksRepository.ts +++ /dev/null @@ -1,1012 +0,0 @@ -import type { Pool } from "pg"; -import type { IChecksRepository } from "@/repositories/checks/IChecksRepository.js"; -import type { - Check, - CheckAudits, - CheckCaptureInfo, - CheckCpuInfo, - CheckDiskInfo, - CheckErrorInfo, - CheckHostInfo, - CheckMemoryInfo, - CheckNetworkInterfaceInfo, - ChecksQueryResult, - ChecksSummary, - MonitorType, - PageSpeedChecksResult, - HardwareChecksResult, - UptimeChecksResult, - GotTimings, -} from "@/types/index.js"; -import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js"; -import { getDateForRange } from "@/utils/dataUtils.js"; - -// Map MongoDB dateString formats to TimescaleDB time_bucket intervals -const dateStringToBucket = (dateString: string): string => { - if (dateString.includes("%M")) return "1 minute"; - if (dateString.includes("%H")) return "1 hour"; - return "1 day"; -}; - -export class TimescaleChecksRepository implements IChecksRepository { - constructor(private pool: Pool) {} - - // Returns correct CA table name for the given bucket interval and query type - // Or returns null if no CA is available (fall back to raw checks table). - private getCaTable(bucket: string, type: "uptime" | "hardware" | "pagespeed"): string | null { - if (type === "uptime" && bucket === "1 hour") return "checks_hourly"; - if (type === "uptime" && bucket === "1 day") return "checks_daily"; - if (type === "hardware" && bucket === "1 hour") return "hardware_hourly"; - if (type === "pagespeed" && bucket === "1 day") return "pagespeed_daily"; - return null; - } - - create = async (check: Check): Promise => { - const row = await this.insertCheck(check); - if (!row) { - throw new Error("Failed to create check"); - } - - if (check.disk?.length) { - await this.insertDisks(row.id, row.created_at, check.disk); - } - if (check.net?.length) { - await this.insertNetworkInterfaces(row.id, row.created_at, check.net); - } - if (check.errors?.length) { - await this.insertErrors(row.id, row.created_at, check.errors); - } - - return this.toEntity(row, check.disk, check.errors, check.net); - }; - - createChecks = async (checks: Check[]): Promise => { - const results: Check[] = []; - for (const check of checks) { - results.push(await this.create(check)); - } - return results; - }; - - findByMonitorId = async ( - monitorId: string, - sortOrder: string, - dateRange: string, - filter: string | undefined, - page: number, - rowsPerPage: number, - status: boolean | undefined - ): Promise => { - const { conditions, values, paramIndex } = this.buildCheckFilters({ monitorId, dateRange, filter, status }, "monitor_id"); - - const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const offset = page && rowsPerPage ? page * rowsPerPage : 0; - - const [countResult, checksResult] = await Promise.all([ - this.pool.query(`SELECT COUNT(*)::int AS count FROM checks WHERE ${conditions.join(" AND ")}`, values), - this.pool.query( - `SELECT * FROM checks WHERE ${conditions.join(" AND ")} ORDER BY created_at ${direction} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, - [...values, rowsPerPage, offset] - ), - ]); - - return { - checksCount: countResult.rows[0].count, - checks: await this.populateChildData(checksResult.rows), - }; - }; - - findByTeamId = async ( - sortOrder: string, - dateRange: string, - filter: string | undefined, - page: number, - rowsPerPage: number, - teamId: string - ): Promise => { - const { conditions, values, paramIndex } = this.buildCheckFilters({ teamId, dateRange, filter }, "team_id"); - - const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const offset = page && rowsPerPage ? page * rowsPerPage : 0; - - const [countResult, checksResult] = await Promise.all([ - this.pool.query(`SELECT COUNT(*)::int AS count FROM checks WHERE ${conditions.join(" AND ")}`, values), - this.pool.query( - `SELECT * FROM checks WHERE ${conditions.join(" AND ")} ORDER BY created_at ${direction} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, - [...values, rowsPerPage, offset] - ), - ]); - - return { - checksCount: countResult.rows[0].count, - checks: await this.populateChildData(checksResult.rows), - }; - }; - - findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { - if (!monitorIds.length) { - return {}; - } - const limitPerMonitor = options?.limitPerMonitor ?? 25; - const dateFilter = new Date(Date.now() - 24 * 60 * 60 * 1000); - - const allResult = await this.pool.query( - `SELECT * FROM ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY created_at DESC) AS rn - FROM checks - WHERE monitor_id = ANY($1) AND created_at >= $2 - ) sub - WHERE rn <= $3 - ORDER BY monitor_id, created_at DESC`, - [monitorIds, dateFilter, limitPerMonitor] - ); - - const checks = await this.populateChildData(allResult.rows); - - const mapped: LatestChecksMap = {}; - for (let i = 0; i < allResult.rows.length; i++) { - const mid = allResult.rows[i].monitor_id; - if (!mapped[mid]) { - mapped[mid] = []; - } - const check = checks[i]; - if (check !== undefined) { - mapped[mid].push(check); - } - } - // Ensure all requested IDs have an entry - for (const id of monitorIds) { - if (!mapped[id]) { - mapped[id] = []; - } - } - return mapped; - }; - - findByDateRangeAndMonitorId = async ( - monitorId: string, - startDate: Date, - endDate: Date, - dateString: string, - options?: { type?: MonitorType } - ): Promise => { - if (options?.type === "hardware") { - return this.findHardwareDateRangeChecks(monitorId, startDate, endDate, dateString); - } - if (options?.type === "pagespeed") { - return this.findPageSpeedDateRangeChecks(monitorId, startDate, endDate, dateString); - } - return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorId, startDate, endDate, dateString); - }; - - findSummaryByTeamId = async (teamId: string, dateRange: string): Promise => { - const rangeDate = getDateForRange(dateRange); - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - - if (rangeDate) { - conditions.push("created_at >= $2"); - values.push(rangeDate); - } - - const result = await this.pool.query( - `SELECT - COUNT(*)::int AS "totalChecks", - COUNT(*) FILTER (WHERE status = FALSE)::int AS "downChecks" - FROM checks WHERE ${conditions.join(" AND ")}`, - values - ); - - return result.rows[0] ?? { totalChecks: 0, downChecks: 0 }; - }; - - deleteByMonitorId = async (monitorId: string): Promise => { - const result = await this.pool.query(`DELETE FROM checks WHERE monitor_id = $1`, [monitorId]); - return result.rowCount ?? 0; - }; - - deleteByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query(`DELETE FROM checks WHERE team_id = $1`, [teamId]); - return result.rowCount ?? 0; - }; - - deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - const result = await this.pool.query(`DELETE FROM checks`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM checks WHERE monitor_id != ALL($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - deleteOlderThan = async (date: Date): Promise => { - const result = await this.pool.query(`DELETE FROM checks WHERE created_at < $1`, [date]); - return result.rowCount ?? 0; - }; - - private populateChildData = async (checkRows: Record[]): Promise => { - if (!checkRows.length) return []; - - const checkIds = checkRows.map((r) => r.id as string); - - const [disksResult, netsResult, errsResult] = await Promise.all([ - this.pool.query( - `SELECT check_id, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, - read_bytes, write_bytes, read_time, write_time - FROM check_disks WHERE check_id = ANY($1)`, - [checkIds] - ), - this.pool.query( - `SELECT check_id, name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out - FROM check_network_interfaces WHERE check_id = ANY($1)`, - [checkIds] - ), - this.pool.query( - `SELECT check_id, metrics, error - FROM check_errors WHERE check_id = ANY($1)`, - [checkIds] - ), - ]); - - const diskMap = new Map(); - for (const d of disksResult.rows) { - const key = d.check_id as string; - if (!diskMap.has(key)) diskMap.set(key, []); - diskMap.get(key)!.push({ - device: d.device, - mountpoint: d.mountpoint, - total_bytes: Number(d.total_bytes), - free_bytes: Number(d.free_bytes), - used_bytes: Number(d.used_bytes), - usage_percent: d.usage_percent, - total_inodes: Number(d.total_inodes), - free_inodes: Number(d.free_inodes), - used_inodes: Number(d.used_inodes), - inodes_usage_percent: d.inodes_usage_percent, - read_bytes: Number(d.read_bytes), - write_bytes: Number(d.write_bytes), - read_time: Number(d.read_time), - write_time: Number(d.write_time), - }); - } - - const netMap = new Map(); - for (const n of netsResult.rows) { - const key = n.check_id as string; - if (!netMap.has(key)) netMap.set(key, []); - netMap.get(key)!.push({ - name: n.name, - bytes_sent: Number(n.bytes_sent), - bytes_recv: Number(n.bytes_recv), - packets_sent: Number(n.packets_sent), - packets_recv: Number(n.packets_recv), - err_in: Number(n.err_in), - err_out: Number(n.err_out), - drop_in: Number(n.drop_in), - drop_out: Number(n.drop_out), - fifo_in: Number(n.fifo_in), - fifo_out: Number(n.fifo_out), - }); - } - - const errMap = new Map(); - for (const e of errsResult.rows) { - const key = e.check_id as string; - if (!errMap.has(key)) errMap.set(key, []); - errMap.get(key)!.push({ metric: e.metrics ?? [], err: e.error ?? "" }); - } - - return checkRows.map((row) => { - const id = row.id as string; - return this.toEntity(row, diskMap.get(id) ?? [], errMap.get(id) ?? [], netMap.get(id) ?? []); - }); - }; - - private insertCheck = async (check: Check) => { - const result = await this.pool.query( - `INSERT INTO checks ( - monitor_id, team_id, monitor_type, status, response_time, status_code, message, - timing_start, timing_socket, timing_lookup, timing_connect, timing_secure_connect, - timing_upload, timing_response, timing_end, - phase_wait, phase_dns, phase_tcp, phase_tls, phase_request, phase_first_byte, phase_download, phase_total, - cpu_physical_core, cpu_logical_core, cpu_frequency, cpu_current_frequency, cpu_temperature, - cpu_free_percent, cpu_usage_percent, - mem_total_bytes, mem_available_bytes, mem_used_bytes, mem_usage_percent, - host_os, host_platform, host_kernel_version, host_pretty_name, - capture_version, capture_mode, - lighthouse_performance, lighthouse_accessibility, lighthouse_best_practices, lighthouse_seo, - audit_cls_score, audit_cls_value, audit_cls_display, - audit_si_score, audit_si_value, audit_si_display, - audit_fcp_score, audit_fcp_value, audit_fcp_display, - audit_lcp_score, audit_lcp_value, audit_lcp_display, - audit_tbt_score, audit_tbt_value, audit_tbt_display - ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23, - $24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43,$44, - $45,$46,$47,$48,$49,$50,$51,$52,$53,$54,$55,$56,$57,$58,$59 - ) RETURNING *`, - [ - check.metadata.monitorId, - check.metadata.teamId, - check.metadata.type, - check.status, - check.responseTime, - check.statusCode, - check.message, - check.timings?.start ?? null, - check.timings?.socket ?? null, - check.timings?.lookup ?? null, - check.timings?.connect ?? null, - check.timings?.secureConnect ?? null, - check.timings?.upload ?? null, - check.timings?.response ?? null, - check.timings?.end ?? null, - check.timings?.phases?.wait ?? null, - check.timings?.phases?.dns ?? null, - check.timings?.phases?.tcp ?? null, - check.timings?.phases?.tls ?? null, - check.timings?.phases?.request ?? null, - check.timings?.phases?.firstByte ?? null, - check.timings?.phases?.download ?? null, - check.timings?.phases?.total ?? null, - check.cpu?.physical_core ?? null, - check.cpu?.logical_core ?? null, - check.cpu?.frequency ?? null, - check.cpu?.current_frequency ?? null, - check.cpu?.temperature ?? null, - check.cpu?.free_percent ?? null, - check.cpu?.usage_percent ?? null, - check.memory?.total_bytes ?? null, - check.memory?.available_bytes ?? null, - check.memory?.used_bytes ?? null, - check.memory?.usage_percent ?? null, - check.host?.os ?? null, - check.host?.platform ?? null, - check.host?.kernel_version ?? null, - check.host?.pretty_name ?? null, - check.capture?.version ?? null, - check.capture?.mode ?? null, - check.performance ?? null, - check.accessibility ?? null, - check.bestPractices ?? null, - check.seo ?? null, - check.audits?.cls?.score ?? null, - check.audits?.cls?.numericValue ?? null, - check.audits?.cls?.displayValue ?? null, - check.audits?.si?.score ?? null, - check.audits?.si?.numericValue ?? null, - check.audits?.si?.displayValue ?? null, - check.audits?.fcp?.score ?? null, - check.audits?.fcp?.numericValue ?? null, - check.audits?.fcp?.displayValue ?? null, - check.audits?.lcp?.score ?? null, - check.audits?.lcp?.numericValue ?? null, - check.audits?.lcp?.displayValue ?? null, - check.audits?.tbt?.score ?? null, - check.audits?.tbt?.numericValue ?? null, - check.audits?.tbt?.displayValue ?? null, - ] - ); - return result.rows[0]; - }; - - private insertDisks = async (checkId: string, checkCreatedAt: Date, disks: CheckDiskInfo[]) => { - for (const disk of disks) { - await this.pool.query( - `INSERT INTO check_disks (check_id, check_created_at, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, read_bytes, write_bytes, read_time, write_time) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, - [ - checkId, - checkCreatedAt, - disk.device, - disk.mountpoint, - disk.total_bytes, - disk.free_bytes, - disk.used_bytes, - disk.usage_percent, - disk.total_inodes, - disk.free_inodes, - disk.used_inodes, - disk.inodes_usage_percent, - disk.read_bytes, - disk.write_bytes, - disk.read_time, - disk.write_time, - ] - ); - } - }; - - private insertNetworkInterfaces = async (checkId: string, checkCreatedAt: Date, nets: CheckNetworkInterfaceInfo[]) => { - for (const net of nets) { - await this.pool.query( - `INSERT INTO check_network_interfaces (check_id, check_created_at, name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, - [ - checkId, - checkCreatedAt, - net.name, - net.bytes_sent, - net.bytes_recv, - net.packets_sent, - net.packets_recv, - net.err_in, - net.err_out, - net.drop_in, - net.drop_out, - net.fifo_in, - net.fifo_out, - ] - ); - } - }; - - private insertErrors = async (checkId: string, checkCreatedAt: Date, errors: CheckErrorInfo[]) => { - for (const err of errors) { - await this.pool.query( - `INSERT INTO check_errors (check_id, check_created_at, metrics, error) - VALUES ($1,$2,$3,$4)`, - [checkId, checkCreatedAt, err.metric, err.err] - ); - } - }; - - private buildCheckFilters = ( - opts: { monitorId?: string; teamId?: string; dateRange?: string; filter?: string; status?: boolean }, - idColumn: string - ) => { - const conditions: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - if (opts.monitorId) { - conditions.push(`${idColumn} = $${paramIndex++}`); - values.push(opts.monitorId); - } else if (opts.teamId) { - conditions.push(`${idColumn} = $${paramIndex++}`); - values.push(opts.teamId); - } - - if (opts.dateRange) { - const rangeDate = getDateForRange(opts.dateRange); - if (rangeDate) { - conditions.push(`created_at >= $${paramIndex++}`); - values.push(rangeDate); - } - } - - // Filter overwrites status (matching Mongo behavior where filter sets matchStage.status) - let statusApplied = false; - if (opts.filter !== undefined) { - switch (opts.filter) { - case "all": - break; - case "up": - conditions.push(`status = TRUE`); - statusApplied = true; - break; - case "down": - conditions.push(`status = FALSE`); - statusApplied = true; - break; - case "resolve": - conditions.push(`status = FALSE`); - conditions.push(`status_code = 5000`); - statusApplied = true; - break; - default: - break; - } - } - - if (!statusApplied && opts.status !== undefined) { - conditions.push(`status = $${paramIndex++}`); - values.push(opts.status); - } - - return { conditions, values, paramIndex }; - }; - - private findUptimeDateRangeChecks = async ( - monitorType: Exclude, - monitorId: string, - startDate: Date, - endDate: Date, - dateString: string - ): Promise => { - const bucket = dateStringToBucket(dateString); - const caTable = this.getCaTable(bucket, "uptime"); - - if (caTable) { - return this.findUptimeFromCa(monitorType, monitorId, startDate, endDate, caTable); - } - return this.findUptimeFromRaw(monitorType, monitorId, startDate, endDate, bucket); - }; - - private findUptimeFromCa = async ( - monitorType: Exclude, - monitorId: string, - startDate: Date, - endDate: Date, - caTable: string - ): Promise => { - const result = await this.pool.query( - `SELECT bucket AS bucket_date, total_checks::int, up_checks::int, down_checks::int, - avg_response_time, avg_up_response_time, avg_down_response_time - FROM ${caTable} - WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 - ORDER BY bucket`, - [monitorId, startDate, endDate] - ); - - let totalChecks = 0; - let totalUp = 0; - let weightedResponseTime = 0; - - const groupedChecks = result.rows.map((row) => { - const count = Number(row.total_checks); - const up = Number(row.up_checks); - const avg = Number(row.avg_response_time ?? 0); - totalChecks += count; - totalUp += up; - weightedResponseTime += avg * count; - return { - bucketDate: (row.bucket_date as Date).toISOString(), - avgResponseTime: avg, - totalChecks: count, - }; - }); - - const groupedUpChecks = result.rows - .filter((row) => Number(row.up_checks) > 0) - .map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - avgResponseTime: Number(row.avg_up_response_time ?? 0), - totalChecks: Number(row.up_checks), - })); - - const groupedDownChecks = result.rows - .filter((row) => Number(row.down_checks) > 0) - .map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - avgResponseTime: Number(row.avg_down_response_time ?? 0), - totalChecks: Number(row.down_checks), - })); - - return { - monitorType, - groupedChecks, - groupedUpChecks, - groupedDownChecks, - uptimePercentage: totalChecks > 0 ? totalUp / totalChecks : 0, - avgResponseTime: totalChecks > 0 ? weightedResponseTime / totalChecks : 0, - }; - }; - - private findUptimeFromRaw = async ( - monitorType: Exclude, - monitorId: string, - startDate: Date, - endDate: Date, - bucket: string - ): Promise => { - const [uptimeResult, groupedResult, upResult, downResult] = await Promise.all([ - this.pool.query( - `SELECT - COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, - COUNT(*) AS total_checks, - AVG(response_time) AS avg_response_time - FROM checks WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3`, - [monitorId, startDate, endDate] - ), - this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(response_time) AS "avgResponseTime", - COUNT(*)::int AS "totalChecks" - FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ), - this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(response_time) AS "avgResponseTime", - COUNT(*)::int AS "totalChecks" - FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 AND status = TRUE - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ), - this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(response_time) AS "avgResponseTime", - COUNT(*)::int AS "totalChecks" - FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 AND status = FALSE - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ), - ]); - - const stats = uptimeResult.rows[0]; - const totalChecks = Number(stats?.total_checks ?? 0); - const upChecks = Number(stats?.up_checks ?? 0); - - const formatGrouped = (rows: Array>) => - rows.map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - avgResponseTime: Number(row.avgResponseTime ?? 0), - totalChecks: Number(row.totalChecks ?? 0), - })); - - return { - monitorType, - groupedChecks: formatGrouped(groupedResult.rows), - groupedUpChecks: formatGrouped(upResult.rows), - groupedDownChecks: formatGrouped(downResult.rows), - uptimePercentage: totalChecks > 0 ? upChecks / totalChecks : 0, - avgResponseTime: Number(stats?.avg_response_time ?? 0), - }; - }; - - private findHardwareDateRangeChecks = async ( - monitorId: string, - startDate: Date, - endDate: Date, - dateString: string - ): Promise => { - const bucket = dateStringToBucket(dateString); - const caTable = this.getCaTable(bucket, "hardware"); - - // Build the metrics query: use hardware_hourly CA for cpu/mem when available, - // but temperature always requires the raw table - const metricsQuery = caTable - ? this.pool.query( - `SELECT bucket AS bucket_date, avg_cpu, avg_memory - FROM ${caTable} - WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 - ORDER BY bucket`, - [monitorId, startDate, endDate] - ) - : this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(cpu_usage_percent) AS avg_cpu, - AVG(mem_usage_percent) AS avg_memory - FROM checks - WHERE monitor_id = $2 AND monitor_type = 'hardware' AND created_at >= $3 AND created_at <= $4 - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ); - - // Temperature query always hits raw table (not in any CA) - const tempQuery = this.pool.query( - `WITH temp_avg AS ( - SELECT time_bucket($1::interval, c.created_at) AS bucket_date, idx, AVG(val) AS avg_val - FROM checks c, unnest(c.cpu_temperature) WITH ORDINALITY AS t(val, idx) - WHERE c.monitor_id = $2 AND c.monitor_type = 'hardware' AND c.created_at >= $3 AND c.created_at <= $4 - GROUP BY bucket_date, idx - ) - SELECT bucket_date, array_agg(avg_val ORDER BY idx) AS avg_temperature - FROM temp_avg - GROUP BY bucket_date - ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ); - - const diskQuery = this.pool.query( - `SELECT - time_bucket($1::interval, d.check_created_at) AS bucket_date, - d.device AS name, - AVG(d.read_bytes) AS "readSpeed", - AVG(d.write_bytes) AS "writeSpeed", - AVG(d.total_bytes) AS "totalBytes", - AVG(d.free_bytes) AS "freeBytes", - AVG(d.usage_percent) AS "usagePercent" - FROM check_disks d - WHERE d.check_id IN ( - SELECT id FROM checks - WHERE monitor_id = $2 AND monitor_type = 'hardware' AND created_at >= $3 AND created_at <= $4 - ) - GROUP BY bucket_date, d.device - ORDER BY bucket_date, d.device`, - [bucket, monitorId, startDate, endDate] - ); - - // Batched network query across all buckets (eliminates N+1) - const netQuery = this.pool.query( - `WITH bounded AS ( - SELECT - time_bucket($1::interval, c.created_at) AS bucket_date, - n.name, - FIRST_VALUE(n.bytes_sent) OVER w AS first_bytes_sent, - LAST_VALUE(n.bytes_sent) OVER w AS last_bytes_sent, - FIRST_VALUE(n.bytes_recv) OVER w AS first_bytes_recv, - LAST_VALUE(n.bytes_recv) OVER w AS last_bytes_recv, - FIRST_VALUE(n.packets_sent) OVER w AS first_packets_sent, - LAST_VALUE(n.packets_sent) OVER w AS last_packets_sent, - FIRST_VALUE(n.packets_recv) OVER w AS first_packets_recv, - LAST_VALUE(n.packets_recv) OVER w AS last_packets_recv, - FIRST_VALUE(n.err_in) OVER w AS first_err_in, - LAST_VALUE(n.err_in) OVER w AS last_err_in, - FIRST_VALUE(n.err_out) OVER w AS first_err_out, - LAST_VALUE(n.err_out) OVER w AS last_err_out, - FIRST_VALUE(n.drop_in) OVER w AS first_drop_in, - LAST_VALUE(n.drop_in) OVER w AS last_drop_in, - FIRST_VALUE(n.drop_out) OVER w AS first_drop_out, - LAST_VALUE(n.drop_out) OVER w AS last_drop_out, - FIRST_VALUE(n.fifo_in) OVER w AS first_fifo_in, - LAST_VALUE(n.fifo_in) OVER w AS last_fifo_in, - FIRST_VALUE(n.fifo_out) OVER w AS first_fifo_out, - LAST_VALUE(n.fifo_out) OVER w AS last_fifo_out, - FIRST_VALUE(c.created_at) OVER w AS first_time, - LAST_VALUE(c.created_at) OVER w AS last_time - FROM check_network_interfaces n - JOIN checks c ON c.id = n.check_id - WHERE c.monitor_id = $2 AND c.monitor_type = 'hardware' - AND c.created_at >= $3 AND c.created_at <= $4 - WINDOW w AS (PARTITION BY time_bucket($1::interval, c.created_at), n.name - ORDER BY c.created_at ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) - ) - SELECT DISTINCT ON (bucket_date, name) bucket_date, name, - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_bytes_sent - first_bytes_sent) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "bytesSentPerSecond", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_bytes_recv - first_bytes_recv) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaBytesRecv", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_packets_sent - first_packets_sent) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaPacketsSent", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_packets_recv - first_packets_recv) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaPacketsRecv", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_err_in - first_err_in) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaErrIn", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_err_out - first_err_out) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaErrOut", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_drop_in - first_drop_in) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaDropIn", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_drop_out - first_drop_out) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaDropOut", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_fifo_in - first_fifo_in) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaFifoIn", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_fifo_out - first_fifo_out) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaFifoOut" - FROM bounded - ORDER BY bucket_date, name`, - [bucket, monitorId, startDate, endDate] - ); - - const [totalResult, upResult, metricsResult, tempResult, diskResult, netResult] = await Promise.all([ - this.pool.query( - `SELECT COUNT(*)::int AS count FROM checks - WHERE monitor_id = $1 AND monitor_type = 'hardware' AND created_at >= $2 AND created_at <= $3`, - [monitorId, startDate, endDate] - ), - this.pool.query( - `SELECT COUNT(*)::int AS count FROM checks - WHERE monitor_id = $1 AND monitor_type = 'hardware' AND created_at >= $2 AND created_at <= $3 AND status = TRUE`, - [monitorId, startDate, endDate] - ), - metricsQuery, - tempQuery, - diskQuery, - netQuery, - ]); - - // Group disk and net results by bucket date - const disksByBucket = new Map(); - for (const row of diskResult.rows) { - const key = (row.bucket_date as Date).toISOString(); - if (!disksByBucket.has(key)) disksByBucket.set(key, []); - disksByBucket.get(key)!.push(row); - } - - const netsByBucket = new Map(); - for (const row of netResult.rows) { - const key = (row.bucket_date as Date).toISOString(); - if (!netsByBucket.has(key)) netsByBucket.set(key, []); - netsByBucket.get(key)!.push(row); - } - - const tempByBucket = new Map(); - for (const row of tempResult.rows) { - const key = (row.bucket_date as Date).toISOString(); - tempByBucket.set(key, Array.isArray(row.avg_temperature) ? row.avg_temperature.map(Number) : []); - } - - // Assemble checks array from metrics + disk/net maps - const checks = metricsResult.rows.map((row) => { - const bucketDate = (row.bucket_date as Date).toISOString(); - const disks = disksByBucket.get(bucketDate) ?? []; - const nets = netsByBucket.get(bucketDate) ?? []; - - return { - bucketDate, - avgCpuUsage: Number(row.avg_cpu ?? 0), - avgMemoryUsage: Number(row.avg_memory ?? 0), - avgTemperature: tempByBucket.get(bucketDate) ?? [], - disks: disks.map((d) => ({ - name: d.name ?? "", - readSpeed: Number(d.readSpeed ?? 0), - writeSpeed: Number(d.writeSpeed ?? 0), - totalBytes: Number(d.totalBytes ?? 0), - freeBytes: Number(d.freeBytes ?? 0), - usagePercent: Number(d.usagePercent ?? 0), - })), - net: nets.map((n) => ({ - name: n.name ?? "", - bytesSentPerSecond: Number(n.bytesSentPerSecond ?? 0), - deltaBytesRecv: Number(n.deltaBytesRecv ?? 0), - deltaPacketsSent: Number(n.deltaPacketsSent ?? 0), - deltaPacketsRecv: Number(n.deltaPacketsRecv ?? 0), - deltaErrIn: Number(n.deltaErrIn ?? 0), - deltaErrOut: Number(n.deltaErrOut ?? 0), - deltaDropIn: Number(n.deltaDropIn ?? 0), - deltaDropOut: Number(n.deltaDropOut ?? 0), - deltaFifoIn: Number(n.deltaFifoIn ?? 0), - deltaFifoOut: Number(n.deltaFifoOut ?? 0), - })), - }; - }); - - return { - monitorType: "hardware" as const, - aggregateData: { totalChecks: totalResult.rows[0].count }, - upChecks: { totalChecks: upResult.rows[0].count }, - checks, - }; - }; - - private findPageSpeedDateRangeChecks = async ( - monitorId: string, - startDate: Date, - endDate: Date, - dateString: string - ): Promise => { - const bucket = dateStringToBucket(dateString); - const caTable = this.getCaTable(bucket, "pagespeed"); - - const result = caTable - ? await this.pool.query( - `SELECT bucket AS bucket_date, - avg_performance AS performance, - avg_accessibility AS accessibility, - avg_best_practices AS "bestPractices", - avg_seo AS seo, - sample_count::int AS "totalChecks" - FROM ${caTable} - WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 - ORDER BY bucket`, - [monitorId, startDate, endDate] - ) - : await this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(lighthouse_performance) AS performance, - AVG(lighthouse_accessibility) AS accessibility, - AVG(lighthouse_best_practices) AS "bestPractices", - AVG(lighthouse_seo) AS seo, - COUNT(*)::int AS "totalChecks" - FROM checks - WHERE monitor_id = $2 AND monitor_type = 'pagespeed' AND created_at >= $3 AND created_at <= $4 - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ); - - return { - monitorType: "pagespeed" as const, - groupedChecks: result.rows.map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - performance: Number(row.performance ?? 0), - accessibility: Number(row.accessibility ?? 0), - bestPractices: Number(row.bestPractices ?? 0), - seo: Number(row.seo ?? 0), - totalChecks: Number(row.totalChecks ?? 0), - })), - }; - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private toEntity = (row: any, disk?: CheckDiskInfo[], errors?: CheckErrorInfo[], net?: CheckNetworkInterfaceInfo[]): Check => { - const timings: GotTimings = { - start: row.timing_start ?? 0, - socket: row.timing_socket ?? 0, - lookup: row.timing_lookup ?? 0, - connect: row.timing_connect ?? 0, - secureConnect: row.timing_secure_connect ?? 0, - upload: row.timing_upload ?? 0, - response: row.timing_response ?? 0, - end: row.timing_end ?? 0, - phases: { - wait: row.phase_wait ?? 0, - dns: row.phase_dns ?? 0, - tcp: row.phase_tcp ?? 0, - tls: row.phase_tls ?? 0, - request: row.phase_request ?? 0, - firstByte: row.phase_first_byte ?? 0, - download: row.phase_download ?? 0, - total: row.phase_total ?? 0, - }, - }; - - const cpu: CheckCpuInfo = { - physical_core: row.cpu_physical_core ?? 0, - logical_core: row.cpu_logical_core ?? 0, - frequency: row.cpu_frequency ?? 0, - current_frequency: row.cpu_current_frequency ?? 0, - temperature: row.cpu_temperature ?? [], - free_percent: row.cpu_free_percent ?? 0, - usage_percent: row.cpu_usage_percent ?? 0, - }; - - const memory: CheckMemoryInfo = { - total_bytes: Number(row.mem_total_bytes ?? 0), - available_bytes: Number(row.mem_available_bytes ?? 0), - used_bytes: Number(row.mem_used_bytes ?? 0), - usage_percent: row.mem_usage_percent ?? 0, - }; - - const host: CheckHostInfo = { - os: row.host_os ?? "", - platform: row.host_platform ?? "", - kernel_version: row.host_kernel_version ?? "", - pretty_name: row.host_pretty_name ?? "", - }; - - const capture: CheckCaptureInfo = { - version: row.capture_version ?? "", - mode: row.capture_mode ?? "", - }; - - const audits: CheckAudits | undefined = - row.audit_cls_score !== null - ? { - cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, - si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, - fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, - lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, - tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, - } - : undefined; - - return { - id: row.id, - metadata: { - monitorId: row.monitor_id, - teamId: row.team_id, - type: row.monitor_type, - }, - status: row.status ?? false, - responseTime: row.response_time ?? 0, - timings, - statusCode: row.status_code ?? 0, - message: row.message ?? "", - cpu, - memory, - disk: disk ?? [], - host, - errors: errors ?? [], - capture, - net: net ?? [], - accessibility: row.lighthouse_accessibility ?? undefined, - bestPractices: row.lighthouse_best_practices ?? undefined, - seo: row.lighthouse_seo ?? undefined, - performance: row.lighthouse_performance ?? undefined, - audits, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }; - }; -} diff --git a/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts b/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts deleted file mode 100644 index e4528f259..000000000 --- a/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { Pool } from "pg"; -import type { IGeoChecksRepository, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js"; -import type { GeoCheck, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck, MonitorType } from "@/types/index.js"; -import { getDateForRange } from "@/utils/dataUtils.js"; - -const dateStringToBucket = (dateString: string): string => { - if (dateString.includes("%M")) return "1 minute"; - if (dateString.includes("%H")) return "1 hour"; - return "1 day"; -}; - -export class TimescaleGeoChecksRepository implements IGeoChecksRepository { - constructor(private pool: Pool) {} - - createGeoChecks = async (geoChecks: Omit[]): Promise => { - const created: GeoCheck[] = []; - - for (const geoCheck of geoChecks) { - const geoResult = await this.pool.query( - `INSERT INTO geo_checks (monitor_id, team_id, monitor_type, expiry) - VALUES ($1, $2, $3, $4) - RETURNING id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at`, - [geoCheck.metadata.monitorId, geoCheck.metadata.teamId, geoCheck.metadata.type, geoCheck.expiry ? new Date(geoCheck.expiry) : null] - ); - const row = geoResult.rows[0]; - if (!row) continue; - - for (const result of geoCheck.results) { - await this.pool.query( - `INSERT INTO geo_check_results ( - geo_check_id, geo_check_created_at, - status, status_code, - location_continent, location_region, location_country, location_state, location_city, - location_longitude, location_latitude, - timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, - [ - row.id, - row.created_at, - result.status, - result.statusCode, - result.location.continent, - result.location.region, - result.location.country, - result.location.state, - result.location.city, - result.location.longitude, - result.location.latitude, - result.timings.total, - result.timings.dns, - result.timings.tcp, - result.timings.tls, - result.timings.firstByte, - result.timings.download, - ] - ); - } - - created.push(this.toGeoCheck(row, geoCheck.results)); - } - - return created; - }; - - findByMonitorId = async ( - monitorId: string, - sortOrder: string, - dateRange: string, - page: number, - rowsPerPage: number, - continents?: GeoContinent[] - ): Promise => { - const conditions: string[] = ["gc.monitor_id = $1"]; - const values: unknown[] = [monitorId]; - let paramIndex = 2; - - const rangeDate = getDateForRange(dateRange); - if (rangeDate) { - conditions.push(`gc.created_at >= $${paramIndex++}`); - values.push(rangeDate); - } - - if (continents && continents.length > 0) { - conditions.push(`r.location_continent = ANY($${paramIndex++})`); - values.push(continents); - } - - const where = conditions.join(" AND "); - const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const offset = page && rowsPerPage ? page * rowsPerPage : 0; - - const countValues = [...values]; - const dataValues = [...values, rowsPerPage, offset]; - - const [countResult, dataResult] = await Promise.all([ - this.pool.query( - `SELECT COUNT(*)::int AS count - FROM geo_checks gc - JOIN geo_check_results r ON r.geo_check_id = gc.id - WHERE ${where}`, - countValues - ), - this.pool.query( - `SELECT gc.id, gc.monitor_id, gc.team_id, gc.monitor_type, - r.location_continent, r.location_region, r.location_country, r.location_state, r.location_city, - r.location_longitude, r.location_latitude, - r.status, r.status_code, - r.timing_total, r.timing_dns, r.timing_tcp, r.timing_tls, r.timing_first_byte, r.timing_download, - gc.created_at, gc.updated_at - FROM geo_checks gc - JOIN geo_check_results r ON r.geo_check_id = gc.id - WHERE ${where} - ORDER BY gc.created_at ${direction} - LIMIT $${paramIndex++} OFFSET $${paramIndex}`, - dataValues - ), - ]); - - const geoChecks: FlatGeoCheck[] = dataResult.rows.map((row) => ({ - id: `${row.monitor_id}-${new Date(row.created_at).getTime()}-${row.location_continent}-${row.location_city}-${row.id.substring(0, 8)}`, - monitorId: row.monitor_id, - teamId: row.team_id, - type: row.monitor_type, - location: { - continent: row.location_continent, - region: row.location_region, - country: row.location_country, - state: row.location_state, - city: row.location_city, - longitude: row.location_longitude, - latitude: row.location_latitude, - }, - status: row.status, - statusCode: row.status_code, - timings: { - total: row.timing_total ?? 0, - dns: row.timing_dns ?? 0, - tcp: row.timing_tcp ?? 0, - tls: row.timing_tls ?? 0, - firstByte: row.timing_first_byte ?? 0, - download: row.timing_download ?? 0, - }, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - })); - - return { geoChecksCount: countResult.rows[0].count, geoChecks }; - }; - - findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise => { - const geoRows = await this.pool.query( - `SELECT id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at - FROM geo_checks - WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3 - ORDER BY created_at DESC`, - [monitorId, startDate, endDate] - ); - - const geoChecks: GeoCheck[] = []; - for (const row of geoRows.rows) { - const resultsRows = await this.pool.query( - `SELECT status, status_code, - location_continent, location_region, location_country, location_state, location_city, - location_longitude, location_latitude, - timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download - FROM geo_check_results - WHERE geo_check_id = $1`, - [row.id] - ); - - const results = resultsRows.rows.map(this.toGeoCheckResult); - geoChecks.push(this.toGeoCheck(row, results)); - } - - return geoChecks; - }; - - findGroupedByMonitorIdAndDateRange = async ( - monitorId: string, - startDate: Date, - endDate: Date, - dateFormat: string, - continents?: GeoContinent[] - ): Promise => { - const bucket = dateStringToBucket(dateFormat); - - const conditions: string[] = ["gc.monitor_id = $2", "gc.created_at >= $3", "gc.created_at <= $4"]; - const values: unknown[] = [bucket, monitorId, startDate, endDate]; - let paramIndex = 5; - - if (continents && continents.length > 0) { - conditions.push(`r.location_continent = ANY($${paramIndex++})`); - values.push(continents); - } - - const result = await this.pool.query( - `SELECT - time_bucket($1::interval, gc.created_at) AS bucket_date, - r.location_continent AS continent, - ROUND(AVG(r.timing_total)::numeric, 2) AS "avgResponseTime", - COUNT(*)::int AS "totalChecks", - ROUND((100.0 * COUNT(*) FILTER (WHERE r.status = TRUE) / COUNT(*))::numeric, 2) AS "uptimePercentage" - FROM geo_checks gc - JOIN geo_check_results r ON r.geo_check_id = gc.id - WHERE ${conditions.join(" AND ")} - GROUP BY bucket_date, r.location_continent - ORDER BY bucket_date, r.location_continent`, - values - ); - - return result.rows.map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - continent: row.continent as GeoContinent, - avgResponseTime: Number(row.avgResponseTime ?? 0), - totalChecks: Number(row.totalChecks ?? 0), - uptimePercentage: Number(row.uptimePercentage ?? 0), - })); - }; - - deleteByMonitorId = async (monitorId: string): Promise => { - const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id = $1`, [monitorId]); - return result.rowCount ?? 0; - }; - - deleteByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query(`DELETE FROM geo_checks WHERE team_id = $1`, [teamId]); - return result.rowCount ?? 0; - }; - - deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - const result = await this.pool.query(`DELETE FROM geo_checks`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id != ALL($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - private toGeoCheckResult = (row: Record): GeoCheckResult => ({ - location: { - continent: (row.location_continent as GeoContinent) ?? "", - region: (row.location_region as string) ?? "", - country: (row.location_country as string) ?? "", - state: (row.location_state as string) ?? "", - city: (row.location_city as string) ?? "", - longitude: (row.location_longitude as number) ?? 0, - latitude: (row.location_latitude as number) ?? 0, - }, - status: (row.status as boolean) ?? false, - statusCode: (row.status_code as number) ?? 0, - timings: { - total: (row.timing_total as number) ?? 0, - dns: (row.timing_dns as number) ?? 0, - tcp: (row.timing_tcp as number) ?? 0, - tls: (row.timing_tls as number) ?? 0, - firstByte: (row.timing_first_byte as number) ?? 0, - download: (row.timing_download as number) ?? 0, - }, - }); - - private toGeoCheck = (row: Record, results: GeoCheckResult[]): GeoCheck => ({ - id: row.id as string, - metadata: { - monitorId: row.monitor_id as string, - teamId: row.team_id as string, - type: row.monitor_type as MonitorType, - }, - results, - expiry: row.expiry ? (row.expiry as Date).toISOString() : new Date(0).toISOString(), - __v: 0, - createdAt: (row.created_at as Date).toISOString(), - updatedAt: (row.updated_at as Date).toISOString(), - }); -} diff --git a/server/src/repositories/incidents/TimescaleIncidentsRepository.ts b/server/src/repositories/incidents/TimescaleIncidentsRepository.ts deleted file mode 100644 index 8a6ad7d22..000000000 --- a/server/src/repositories/incidents/TimescaleIncidentsRepository.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { Pool } from "pg"; -import type { Incident, IncidentSummary, IncidentResolutionType } from "@/types/incident.js"; -import type { IIncidentsRepository } from "@/repositories/incidents/IIncidentsRepository.js"; -import { AppError } from "@/utils/AppError.js"; - -interface IncidentRow { - id: string; - monitor_id: string; - team_id: string; - start_time: Date; - end_time: Date | null; - status: boolean; - message: string | null; - status_code: number | null; - resolution_type: IncidentResolutionType; - resolved_by: string | null; - resolved_by_email: string | null; - comment: string | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, monitor_id, team_id, start_time, end_time, status, message, status_code, - resolution_type, resolved_by, resolved_by_email, comment, created_at, updated_at`; - -export class TimescaleIncidentsRepository implements IIncidentsRepository { - constructor(private pool: Pool) {} - - create = async (incident: Partial): Promise => { - const result = await this.pool.query( - `INSERT INTO incidents (monitor_id, team_id, start_time, end_time, status, message, status_code, resolution_type, resolved_by, resolved_by_email, comment) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING ${COLUMNS}`, - [ - incident.monitorId, - incident.teamId, - incident.startTime ? new Date(Number(incident.startTime) || incident.startTime) : new Date(), - incident.endTime ? new Date(Number(incident.endTime) || incident.endTime) : null, - incident.status ?? true, - incident.message ?? null, - incident.statusCode ?? null, - incident.resolutionType ?? null, - incident.resolvedBy ?? null, - incident.resolvedByEmail ?? null, - incident.comment ?? null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create incident", status: 500 }); - } - return this.toEntity(row); - }; - - findById = async (incidentId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2`, [incidentId, teamId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Incident with id ${incidentId} not found`, status: 404 }); - } - return this.toEntity(row); - }; - - findActiveByIncidentId = async (incidentId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2 AND status = TRUE`, [ - incidentId, - teamId, - ]); - const row = result.rows[0]; - return row ? this.toEntity(row) : null; - }; - - findActiveByMonitorId = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM incidents WHERE monitor_id = $1 AND team_id = $2 AND status = TRUE`, [ - monitorId, - teamId, - ]); - const row = result.rows[0]; - return row ? this.toEntity(row) : null; - }; - - findByTeamId = async ( - teamId: string, - startDate: Date | undefined, - page: number, - rowsPerPage: number, - sortOrder?: string, - status?: boolean, - monitorId?: string, - resolutionType?: string - ): Promise => { - const { conditions, values, paramIndex } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType); - const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const offset = page * rowsPerPage; - - const result = await this.pool.query( - `SELECT ${COLUMNS} FROM incidents - WHERE ${conditions.join(" AND ")} - ORDER BY created_at ${direction} - LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, - [...values, rowsPerPage, offset] - ); - return result.rows.map(this.toEntity); - }; - - countByTeamId = async ( - teamId: string, - startDate: Date | undefined, - status?: boolean, - monitorId?: string, - resolutionType?: string - ): Promise => { - const { conditions, values } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType); - const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM incidents WHERE ${conditions.join(" AND ")}`, values); - return result.rows[0].count; - }; - - findSummaryByTeamId = async (teamId: string, limit?: number): Promise => { - // Counts by status and resolution type - const countsResult = await this.pool.query( - `SELECT - COUNT(*)::int AS total, - COUNT(*) FILTER (WHERE status = TRUE)::int AS active, - COUNT(*) FILTER (WHERE resolution_type = 'manual')::int AS manual, - COUNT(*) FILTER (WHERE resolution_type = 'automatic')::int AS automatic - FROM incidents WHERE team_id = $1`, - [teamId] - ); - const counts = countsResult.rows[0] ?? { total: 0, active: 0, manual: 0, automatic: 0 }; - - // Average resolution time - const resTimeResult = await this.pool.query( - `SELECT AVG(EXTRACT(EPOCH FROM (end_time - start_time))) AS avg_seconds - FROM incidents - WHERE team_id = $1 AND status = FALSE AND end_time IS NOT NULL`, - [teamId] - ); - const avgSeconds = resTimeResult.rows[0]?.avg_seconds ?? 0; - const avgResolutionTimeHours = Math.round((avgSeconds / 3600) * 100) / 100; - - // Top monitor by incident count - const topMonitorResult = await this.pool.query( - `SELECT i.monitor_id, m.name AS monitor_name, COUNT(*)::int AS count - FROM incidents i - LEFT JOIN monitors m ON m.id = i.monitor_id - WHERE i.team_id = $1 - GROUP BY i.monitor_id, m.name - ORDER BY count DESC - LIMIT 1`, - [teamId] - ); - - // Latest incidents - const latestLimit = Math.max(1, Number.isFinite(Number(limit)) ? Number(limit) : 10); - const latestResult = await this.pool.query( - `SELECT i.id, i.monitor_id, m.name AS monitor_name, i.status, i.start_time, i.end_time, - i.resolution_type, i.message, i.status_code, i.created_at - FROM incidents i - LEFT JOIN monitors m ON m.id = i.monitor_id - WHERE i.team_id = $1 - ORDER BY i.created_at DESC - LIMIT $2`, - [teamId, latestLimit] - ); - - const topRow = topMonitorResult.rows[0]; - - return { - total: counts.total, - totalActive: counts.active, - totalManualResolutions: counts.manual, - totalAutomaticResolutions: counts.automatic, - avgResolutionTimeHours, - topMonitor: topRow - ? { - monitorId: topRow.monitor_id, - monitorName: topRow.monitor_name ?? null, - incidentCount: topRow.count, - } - : null, - latestIncidents: latestResult.rows.map((row) => ({ - id: row.id, - monitorId: row.monitor_id, - monitorName: row.monitor_name ?? null, - status: row.status, - startTime: row.start_time.toISOString(), - endTime: row.end_time ? row.end_time.toISOString() : null, - resolutionType: row.resolution_type ?? null, - message: row.message ?? null, - statusCode: row.status_code ?? null, - createdAt: row.created_at.toISOString(), - })), - }; - }; - - updateById = async (incidentId: string, teamId: string, patch: Partial): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof Incident, string][] = [ - ["status", "status"], - ["message", "message"], - ["statusCode", "status_code"], - ["endTime", "end_time"], - ["resolutionType", "resolution_type"], - ["resolvedBy", "resolved_by"], - ["resolvedByEmail", "resolved_by_email"], - ["comment", "comment"], - ]; - - for (const [key, column] of fieldMap) { - if (patch[key] !== undefined) { - const value = key === "endTime" && patch[key] ? new Date(Number(patch[key]) || (patch[key] as string)) : patch[key]; - sets.push(`${column} = $${paramIndex++}`); - values.push(value); - } - } - - if (sets.length === 0) { - return this.findById(incidentId, teamId); - } - - sets.push(`updated_at = NOW()`); - values.push(incidentId, teamId); - - const result = await this.pool.query( - `UPDATE incidents SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Failed to update incident with id ${incidentId}`, status: 500 }); - } - return this.toEntity(row); - }; - - deleteByMonitorId = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id = $1 AND team_id = $2`, [monitorId, teamId]); - return result.rowCount ?? 0; - }; - - deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - const result = await this.pool.query(`DELETE FROM incidents`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id != ALL($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - private buildWhere = (teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string) => { - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (startDate) { - conditions.push(`created_at >= $${paramIndex++}`); - values.push(startDate); - } - if (status !== undefined) { - conditions.push(`status = $${paramIndex++}`); - values.push(status); - } - if (monitorId) { - conditions.push(`monitor_id = $${paramIndex++}`); - values.push(monitorId); - } - if (resolutionType) { - conditions.push(`resolution_type = $${paramIndex++}`); - values.push(resolutionType); - } - - return { conditions, values, paramIndex }; - }; - - private toEntity = (row: IncidentRow): Incident => ({ - id: row.id, - monitorId: row.monitor_id, - teamId: row.team_id, - startTime: row.start_time.toISOString(), - endTime: row.end_time ? row.end_time.toISOString() : null, - status: row.status, - message: row.message ?? null, - statusCode: row.status_code ?? null, - resolutionType: row.resolution_type ?? null, - resolvedBy: row.resolved_by ?? null, - resolvedByEmail: row.resolved_by_email ?? null, - comment: row.comment ?? null, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 84a9a7cda..fc5eefee9 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,54 +1,41 @@ export * from "@/repositories/monitors/IMonitorsRepository.js"; export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js"; -export { TimescaleMonitorsRepository } from "@/repositories/monitors/TimescaleMonitorsRepository.js"; export * from "@/repositories/checks/IChecksRepository.js"; export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js"; -export { TimescaleChecksRepository } from "@/repositories/checks/TimescaleChecksRepository.js"; export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js"; export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js"; -export { TimescaleMonitorStatsRepository } from "@/repositories/monitor-stats/TimescaleMonitorStatsRepository.js"; export * from "@/repositories/status-pages/IStatusPagesRepository.js"; export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js"; -export { TimescaleStatusPagesRepository } from "@/repositories/status-pages/TimescaleStatusPagesRepository.js"; export * from "@/repositories/users/IUsersRepository.js"; export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js"; -export { TimescaleUsersRepository } from "@/repositories/users/TimescaleUsersRepository.js"; export * from "@/repositories/invites/IInvitesRepository.js"; export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js"; -export { TimescaleInvitesRepository } from "@/repositories/invites/TimescaleInvitesRepository.js"; export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js"; export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js"; -export { TimescaleRecoveryTokensRepository } from "@/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.js"; export * from "@/repositories/settings/ISettingsRepository.js"; export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js"; -export { TimescaleSettingsRepository } from "@/repositories/settings/TimescaleSettingsRepository.js"; export * from "@/repositories/notifications/INotificationsRepository.js"; export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js"; -export { TimescaleNotificationsRepository } from "@/repositories/notifications/TimescaleNotificationsRepository.js"; export * from "@/repositories/incidents/IIncidentsRepository.js"; export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js"; -export { TimescaleIncidentsRepository } from "@/repositories/incidents/TimescaleIncidentsRepository.js"; export * from "@/repositories/teams/ITeamsRepository.js"; export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js"; -export { TimescaleTeamsRepository } from "@/repositories/teams/TimescaleTeamsRepository.js"; export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js"; export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js"; -export { TimescaleMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.js"; export * from "@/repositories/geo-checks/IGeoChecksRepository.js"; export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js"; export * from "@/repositories/dlq/IDLQRepository.js"; export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js"; -export { TimescaleGeoChecksRepository } from "@/repositories/geo-checks/TimescaleGeoChecksRepository.js"; diff --git a/server/src/repositories/invites/TimescaleInvitesRepository.ts b/server/src/repositories/invites/TimescaleInvitesRepository.ts deleted file mode 100644 index 853047745..000000000 --- a/server/src/repositories/invites/TimescaleInvitesRepository.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Pool } from "pg"; -import crypto from "crypto"; -import type { IInvitesRepository } from "@/repositories/invites/IInvitesRepository.js"; -import type { Invite } from "@/types/invite.js"; -import type { UserRole } from "@/types/user.js"; -import { AppError } from "@/utils/AppError.js"; - -const parsePostgresArray = (value: unknown): UserRole[] => { - if (typeof value !== "string") return []; - const inner = value.replace(/^\{|\}$/g, ""); - if (inner === "") return []; - return inner.split(",") as UserRole[]; -}; - -interface InviteRow { - id: string; - email: string; - team_id: string; - roles: UserRole[]; - token: string; - expiry: Date | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, email, team_id, roles, token, expiry, created_at, updated_at`; - -export class TimescaleInvitesRepository implements IInvitesRepository { - constructor(private pool: Pool) {} - - create = async (invite: Partial): Promise => { - // Delete existing invites for this email - await this.pool.query(`DELETE FROM invites WHERE email = $1`, [invite.email]); - - const token = crypto.randomBytes(32).toString("hex"); - const result = await this.pool.query( - `INSERT INTO invites (email, team_id, roles, token, expiry) - VALUES ($1, $2, $3, $4, $5) - RETURNING ${COLUMNS}`, - [invite.email, invite.teamId, invite.role ?? ["user"], token, invite.expiry ? new Date(invite.expiry) : null] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create invite", status: 500 }); - } - return this.toEntity(row); - }; - - findByToken = async (token: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM invites WHERE token = $1`, [token]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Invite not found", status: 404 }); - } - return this.toEntity(row); - }; - - findByTokenAndDelete = async (token: string): Promise => { - const result = await this.pool.query(`DELETE FROM invites WHERE token = $1 RETURNING ${COLUMNS}`, [token]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Invite not found", status: 404 }); - } - return this.toEntity(row); - }; - - private toEntity = (row: InviteRow): Invite => ({ - id: row.id, - email: row.email, - teamId: row.team_id, - role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles), - token: row.token, - expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(), - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts deleted file mode 100644 index cfe28198a..000000000 --- a/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { Pool } from "pg"; -import type { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js"; -import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js"; -import { AppError } from "@/utils/AppError.js"; - -interface MaintenanceWindowRow { - id: string; - monitor_id: string; - team_id: string; - active: boolean; - name: string; - duration: number; - duration_unit: DurationUnit; - repeat: number; - start_time: Date; - end_time: Date; - expiry: Date | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, monitor_id, team_id, active, name, duration, duration_unit, repeat, - start_time, end_time, expiry, created_at, updated_at`; - -export class TimescaleMaintenanceWindowsRepository implements IMaintenanceWindowsRepository { - constructor(private pool: Pool) {} - - create = async (data: Partial): Promise => { - const startTime = data.start ? new Date(data.start) : null; - const endTime = data.end ? new Date(data.end) : null; - // One-time windows expire at end time - const expiry = data.repeat === 0 ? endTime : null; - - const result = await this.pool.query( - `INSERT INTO maintenance_windows (monitor_id, team_id, active, name, duration, duration_unit, repeat, start_time, end_time, expiry) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING ${COLUMNS}`, - [ - data.monitorId, - data.teamId, - data.active ?? true, - data.name ?? null, - data.duration ?? null, - data.durationUnit ?? null, - data.repeat ?? 0, - startTime, - endTime, - expiry, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create maintenance window", status: 500 }); - } - return this.toEntity(row); - }; - - findById = async (id: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM maintenance_windows WHERE id = $1 AND team_id = $2`, [ - id, - teamId, - ]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Maintenance Window not found", status: 404 }); - } - return this.toEntity(row); - }; - - findByMonitorId = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM maintenance_windows WHERE monitor_id = $1 AND team_id = $2`, [ - monitorId, - teamId, - ]); - return result.rows.map(this.toEntity); - }; - - findByTeamId = async ( - teamId: string, - page: number, - rowsPerPage: number, - field?: string, - order?: string, - active?: boolean - ): Promise => { - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (active !== undefined) { - conditions.push(`active = $${paramIndex++}`); - values.push(active); - } - - const fieldMap: Record = { - createdAt: "created_at", - name: "name", - start: "start_time", - end: "end_time", - active: "active", - }; - const sortColumn = field ? (fieldMap[field] ?? "created_at") : "created_at"; - const sortDirection = order === "asc" ? "ASC" : "DESC"; - - const offset = page && rowsPerPage ? page * rowsPerPage : 0; - - const result = await this.pool.query( - `SELECT ${COLUMNS} FROM maintenance_windows - WHERE ${conditions.join(" AND ")} - ORDER BY ${sortColumn} ${sortDirection} - LIMIT $${paramIndex++} OFFSET $${paramIndex}`, - [...values, rowsPerPage, offset] - ); - return result.rows.map(this.toEntity); - }; - - updateById = async (id: string, teamId: string, patch: Partial): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof MaintenanceWindow, string, boolean][] = [ - ["active", "active", false], - ["name", "name", false], - ["duration", "duration", false], - ["durationUnit", "duration_unit", false], - ["repeat", "repeat", false], - ["start", "start_time", true], - ["end", "end_time", true], - ]; - - for (const [key, column, isDate] of fieldMap) { - if (patch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - values.push(isDate ? new Date(patch[key] as string) : patch[key]); - } - } - - if (sets.length === 0) { - return this.findById(id, teamId); - } - - sets.push(`updated_at = NOW()`); - values.push(id, teamId); - - const result = await this.pool.query( - `UPDATE maintenance_windows SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 }); - } - return this.toEntity(row); - }; - - deleteById = async (id: string, teamId: string): Promise => { - const result = await this.pool.query( - `DELETE FROM maintenance_windows WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`, - [id, teamId] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 }); - } - return this.toEntity(row); - }; - - countByTeamId = async (teamId: string, active?: boolean): Promise => { - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - - if (active !== undefined) { - conditions.push(`active = $2`); - values.push(active); - } - - const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM maintenance_windows WHERE ${conditions.join(" AND ")}`, values); - return result.rows[0].count; - }; - - private toEntity = (row: MaintenanceWindowRow): MaintenanceWindow => ({ - id: row.id, - monitorId: row.monitor_id, - teamId: row.team_id, - active: row.active, - name: row.name, - duration: row.duration, - durationUnit: row.duration_unit, - repeat: row.repeat, - start: row.start_time.toISOString(), - end: row.end_time.toISOString(), - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts deleted file mode 100644 index 308dc1b64..000000000 --- a/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { Pool } from "pg"; -import type { IMonitorStatsRepository } from "./IMonitorStatsRepository.js"; -import type { MonitorStats, CheckResultInput } from "@/types/monitorStats.js"; -import { AppError } from "@/utils/AppError.js"; - -interface MonitorStatsRow { - id: string; - monitor_id: string; - avg_response_time: number; - max_response_time: number; - total_checks: number; - total_up_checks: number; - total_down_checks: number; - uptime_percentage: number; - last_check_timestamp: Date | null; - last_response_time: number; - time_of_last_failure: Date | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks, - total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure, - created_at, updated_at`; - -export class TimescaleMonitorStatsRepository implements IMonitorStatsRepository { - constructor(private pool: Pool) {} - - create = async (data: Omit): Promise => { - const result = await this.pool.query( - `INSERT INTO monitor_stats (monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks, - total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING ${COLUMNS}`, - [ - data.monitorId, - data.avgResponseTime, - data.maxResponseTime, - data.totalChecks, - data.totalUpChecks, - data.totalDownChecks, - data.uptimePercentage, - data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null, - data.lastResponseTime, - data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create monitor stats", status: 500 }); - } - return this.toEntity(row); - }; - - findByMonitorId = async (monitorId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM monitor_stats WHERE monitor_id = $1`, [monitorId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Monitor stats not found", status: 404 }); - } - return this.toEntity(row); - }; - - updateByMonitorId = async (monitorId: string, result: CheckResultInput): Promise => { - const { status, responseTime, now } = result; - const upDelta = status ? 1 : 0; - const downDelta = status ? 0 : 1; - const nowTs = new Date(now); - - // Atomic upsert via INSERT ... ON CONFLICT DO UPDATE - const queryResult = await this.pool.query( - `INSERT INTO monitor_stats ( - monitor_id, - avg_response_time, - max_response_time, - total_checks, - total_up_checks, - total_down_checks, - uptime_percentage, - last_check_timestamp, - last_response_time, - time_of_last_failure - ) VALUES ( - $1, - $2, - $2, - 1, - $3, - $4, - $3::float, - $5, - $2, - CASE WHEN $6::boolean THEN $5 ELSE NULL END - ) - ON CONFLICT (monitor_id) DO UPDATE SET - total_checks = monitor_stats.total_checks + 1, - total_up_checks = monitor_stats.total_up_checks + $3, - total_down_checks = monitor_stats.total_down_checks + $4, - avg_response_time = (monitor_stats.avg_response_time * monitor_stats.total_checks + $2) - / (monitor_stats.total_checks + 1), - uptime_percentage = (monitor_stats.total_up_checks + $3)::float - / (monitor_stats.total_checks + 1), - max_response_time = GREATEST(monitor_stats.max_response_time, $2), - last_response_time = $2, - last_check_timestamp = $5, - time_of_last_failure = CASE - WHEN $6::boolean AND monitor_stats.time_of_last_failure IS NULL THEN $5 - WHEN $6::boolean THEN monitor_stats.time_of_last_failure - ELSE NULL - END, - updated_at = NOW() - RETURNING ${COLUMNS}`, - [monitorId, responseTime, upDelta, downDelta, nowTs, status] - ); - const row = queryResult.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to apply check result to monitor stats", status: 500 }); - } - return this.toEntity(row); - }; - - deleteByMonitorId = async (monitorId: string): Promise => { - const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id = $1 RETURNING ${COLUMNS}`, [monitorId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Monitor stats not found", status: 404 }); - } - return this.toEntity(row); - }; - - deleteByMonitorIds = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - return 0; - } - const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id = ANY($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - const result = await this.pool.query(`DELETE FROM monitor_stats`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id != ALL($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - private toEntity = (row: MonitorStatsRow): MonitorStats => ({ - id: row.id, - monitorId: row.monitor_id, - avgResponseTime: row.avg_response_time, - maxResponseTime: row.max_response_time, - totalChecks: Number(row.total_checks), - totalUpChecks: Number(row.total_up_checks), - totalDownChecks: Number(row.total_down_checks), - uptimePercentage: row.uptime_percentage, - lastCheckTimestamp: row.last_check_timestamp ? row.last_check_timestamp.getTime() : 0, - lastResponseTime: row.last_response_time, - timeOfLastFailure: row.time_of_last_failure ? row.time_of_last_failure.getTime() : undefined, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts deleted file mode 100644 index 7cd0aa4f0..000000000 --- a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts +++ /dev/null @@ -1,1093 +0,0 @@ -import type { Pool } from "pg"; -import type { Monitor, MonitorsSummary, MonitorStatus, MonitorType, MonitorMatchMethod, GeoContinent, CheckSnapshot } from "@/types/monitor.js"; -import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; -import { AppError } from "@/utils/AppError.js"; - -interface MonitorRow { - id: string; - user_id: string; - team_id: string; - name: string; - description: string | null; - type: MonitorType; - status: MonitorStatus; - url: string | null; - port: number | null; - ignore_tls_errors: boolean; - use_advanced_matching: boolean; - json_path: string | null; - expected_value: string | null; - match_method: MonitorMatchMethod | null; - secret: string | null; - interval_ms: number; - is_active: boolean; - status_window: boolean[] | null; - status_window_size: number; - status_window_threshold: number; - uptime_percentage: number | null; - cpu_alert_threshold: number; - cpu_alert_counter: number; - memory_alert_threshold: number; - memory_alert_counter: number; - disk_alert_threshold: number; - disk_alert_counter: number; - temp_alert_threshold: number; - temp_alert_counter: number; - selected_disks: string[] | null; - game_id: string | null; - grpc_service_name: string | null; - monitor_group: string | null; - geo_check_enabled: boolean; - geo_check_locations: GeoContinent[] | null; - geo_check_interval_ms: number; - created_at: Date; - updated_at: Date; -} - -const MONITOR_COLUMNS = `id, user_id, team_id, name, description, type, status, url, port, - ignore_tls_errors, use_advanced_matching, json_path, expected_value, match_method, secret, - interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage, - cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, - disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms, - created_at, updated_at`; - -export class TimescaleMonitorsRepository implements IMonitorsRepository { - constructor(private pool: Pool) {} - - create = async (monitor: Monitor, teamId: string, userId: string): Promise => { - const result = await this.pool.query( - `INSERT INTO monitors (user_id, team_id, name, description, type, status, url, port, - ignore_tls_errors, use_advanced_matching, json_path, expected_value, match_method, secret, - interval_ms, is_active, status_window, status_window_size, status_window_threshold, - cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, - disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34) - RETURNING ${MONITOR_COLUMNS}`, - [ - userId, - teamId, - monitor.name, - monitor.description ?? null, - monitor.type, - monitor.status ?? "initializing", - monitor.url ?? null, - monitor.port ?? null, - monitor.ignoreTlsErrors ?? false, - monitor.useAdvancedMatching ?? false, - monitor.jsonPath ?? null, - monitor.expectedValue ?? null, - monitor.matchMethod || null, - monitor.secret ?? null, - monitor.interval ?? 60000, - monitor.isActive ?? true, - monitor.statusWindow ?? null, - monitor.statusWindowSize ?? 5, - monitor.statusWindowThreshold ?? 60, - monitor.cpuAlertThreshold ?? 0, - monitor.cpuAlertCounter ?? 0, - monitor.memoryAlertThreshold ?? 0, - monitor.memoryAlertCounter ?? 0, - monitor.diskAlertThreshold ?? 0, - monitor.diskAlertCounter ?? 0, - monitor.tempAlertThreshold ?? 0, - monitor.tempAlertCounter ?? 0, - monitor.selectedDisks ?? [], - monitor.gameId ?? null, - monitor.grpcServiceName ?? null, - monitor.group ?? null, - monitor.geoCheckEnabled ?? false, - monitor.geoCheckLocations ?? [], - monitor.geoCheckInterval ?? 300000, - ] - ); - const row = result.rows[0]; - if (!row) { - return null; - } - - // Insert notification associations - if (monitor.notifications?.length) { - for (const notificationId of monitor.notifications) { - await this.pool.query(`INSERT INTO monitor_notifications (monitor_id, notification_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [ - row.id, - notificationId, - ]); - } - } - - const entity = this.toEntity(row); - entity.notifications = monitor.notifications ?? []; - return entity; - }; - - createMonitors = async (monitors: Monitor[]): Promise => { - if (!monitors.length) { - return []; - } - const created: Monitor[] = []; - for (const monitor of monitors) { - const result = await this.create(monitor, monitor.teamId, monitor.userId); - if (result) { - created.push(result); - } - } - return created; - }; - - findById = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE id = $1 AND team_id = $2`, [monitorId, teamId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Monitor with ID ${monitorId} not found`, status: 404 }); - } - const monitor = this.toEntity(row); - - // Populate latest check with child data - const checkResult = await this.pool.query(`SELECT * FROM checks WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1`, [monitorId]); - if (checkResult.rows[0]) { - const checkRow = checkResult.rows[0]; - const childData = await this.fetchCheckChildData(checkRow.id); - monitor.recentChecks = [this.toCheckSnapshot(checkRow, childData)]; - } - - // Populate notifications - monitor.notifications = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); - - return monitor; - }; - - findAll = async (): Promise => { - const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors`); - const monitors = result.rows.map(this.toEntity); - if (monitors.length > 0) { - const notifMap = await this.fetchNotificationIds(monitors.map((m) => m.id)); - for (const monitor of monitors) { - monitor.notifications = notifMap.get(monitor.id) ?? []; - } - } - return monitors; - }; - - findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { - const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type } = config ?? {}; - - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (type !== undefined) { - if (Array.isArray(type)) { - conditions.push(`type = ANY($${paramIndex++})`); - values.push(type); - } else { - conditions.push(`type = $${paramIndex++}`); - values.push(type); - } - } - - if (filter !== undefined) { - switch (field) { - case "name": - conditions.push(`(name ILIKE $${paramIndex} OR url ILIKE $${paramIndex})`); - values.push(`%${filter}%`); - paramIndex++; - break; - case "isActive": - conditions.push(`is_active = $${paramIndex++}`); - values.push(filter === "true"); - break; - case "status": - conditions.push(`status = $${paramIndex++}`); - values.push(filter); - break; - case "type": - conditions.push(`type = $${paramIndex++}`); - values.push(filter); - break; - default: - break; - } - } - - const fieldMap: Record = { - createdAt: "created_at", - name: "name", - status: "status", - type: "type", - isActive: "is_active", - }; - const sortColumn = fieldMap[field] ?? "created_at"; - const sortDirection = order === "asc" ? "ASC" : "DESC"; - - let query = `SELECT ${MONITOR_COLUMNS} FROM monitors WHERE ${conditions.join(" AND ")} ORDER BY ${sortColumn} ${sortDirection}`; - - if (rowsPerPage > 0) { - const offset = Math.max(page, 0) * rowsPerPage; - query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; - values.push(rowsPerPage, offset); - } - - const result = await this.pool.query(query, values); - const monitors = result.rows.map(this.toEntity); - - if (monitors.length === 0) return monitors; - - // Populate recentChecks — 25 latest checks per monitor in a single query - const monitorIds = monitors.map((m) => m.id); - const checksResult = await this.pool.query( - `SELECT * FROM ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY created_at DESC) AS rn - FROM checks - WHERE monitor_id = ANY($1) - ) sub WHERE rn <= 25 - ORDER BY monitor_id, created_at ASC`, - [monitorIds] - ); - - // Group checks by monitor_id - const checksMap = new Map(); - for (const row of checksResult.rows) { - if (!checksMap.has(row.monitor_id)) checksMap.set(row.monitor_id, []); - checksMap.get(row.monitor_id)!.push(row); - } - - // Batch fetch child data for all checks in 3 queries - const checkIds = checksResult.rows.map((r) => r.id); - const diskMap = new Map[]>(); - const netMap = new Map[]>(); - const errorsMap = new Map[]>(); - - if (checkIds.length > 0) { - const [disksResult, netsResult, errsResult] = await Promise.all([ - this.pool.query( - `SELECT check_id, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, - read_bytes, write_bytes, read_time, write_time - FROM check_disks WHERE check_id = ANY($1)`, - [checkIds] - ), - this.pool.query( - `SELECT check_id, name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out - FROM check_network_interfaces WHERE check_id = ANY($1)`, - [checkIds] - ), - this.pool.query( - `SELECT check_id, metrics, error - FROM check_errors WHERE check_id = ANY($1)`, - [checkIds] - ), - ]); - - for (const d of disksResult.rows) { - const key = d.check_id as string; - if (!diskMap.has(key)) diskMap.set(key, []); - diskMap.get(key)!.push(d); - } - for (const n of netsResult.rows) { - const key = n.check_id as string; - if (!netMap.has(key)) netMap.set(key, []); - netMap.get(key)!.push(n); - } - for (const e of errsResult.rows) { - const key = e.check_id as string; - if (!errorsMap.has(key)) errorsMap.set(key, []); - errorsMap.get(key)!.push(e); - } - } - - for (const monitor of monitors) { - const rows = checksMap.get(monitor.id) ?? []; - monitor.recentChecks = rows.map((row) => { - const childData = { - disk: (diskMap.get(row.id) ?? []).map((d) => ({ - device: d.device, - mountpoint: d.mountpoint, - total_bytes: Number(d.total_bytes), - free_bytes: Number(d.free_bytes), - used_bytes: Number(d.used_bytes), - usage_percent: d.usage_percent as number, - total_inodes: Number(d.total_inodes), - free_inodes: Number(d.free_inodes), - used_inodes: Number(d.used_inodes), - inodes_usage_percent: d.inodes_usage_percent as number, - read_bytes: Number(d.read_bytes), - write_bytes: Number(d.write_bytes), - read_time: Number(d.read_time), - write_time: Number(d.write_time), - })), - net: (netMap.get(row.id) ?? []).map((n) => ({ - name: n.name as string, - bytes_sent: Number(n.bytes_sent), - bytes_recv: Number(n.bytes_recv), - packets_sent: Number(n.packets_sent), - packets_recv: Number(n.packets_recv), - err_in: Number(n.err_in), - err_out: Number(n.err_out), - drop_in: Number(n.drop_in), - drop_out: Number(n.drop_out), - fifo_in: Number(n.fifo_in), - fifo_out: Number(n.fifo_out), - })), - errors: (errorsMap.get(row.id) ?? []).map((e) => ({ - metric: (e.metrics as string[]) ?? [], - err: (e.error as string) ?? "", - })), - }; - return this.toCheckSnapshot(row, childData); - }); - } - - // Populate notifications in batch - const notifMap = await this.fetchNotificationIds(monitorIds); - for (const monitor of monitors) { - monitor.notifications = notifMap.get(monitor.id) ?? []; - } - - return monitors; - }; - - findByIds = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - return []; - } - const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE id = ANY($1)`, [monitorIds]); - const monitors = result.rows.map(this.toEntity); - const notifMap = await this.fetchNotificationIds(monitorIds); - for (const monitor of monitors) { - monitor.notifications = notifMap.get(monitor.id) ?? []; - } - return monitors; - }; - - findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { - if (!monitorIds.length) { - return []; - } - const monitors = await this.findByIds(monitorIds); - - for (const monitor of monitors) { - const checksResult = await this.pool.query( - `SELECT * FROM checks - WHERE monitor_id = $1 - ORDER BY created_at DESC - LIMIT $2`, - [monitor.id, checksCount] - ); - monitor.recentChecks = await Promise.all( - checksResult.rows.map(async (row) => { - // Fetch child records for hardware monitors - let disk: import("@/types/index.js").CheckDiskInfo[] = []; - let net: import("@/types/index.js").CheckNetworkInterfaceInfo[] = []; - let errors: import("@/types/index.js").CheckErrorInfo[] = []; - - if (monitor.type === "hardware") { - const [diskResult, netResult, errorsResult] = await Promise.all([ - this.pool.query( - `SELECT device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, - read_bytes, write_bytes, read_time, write_time - FROM check_disks WHERE check_id = $1`, - [row.id] - ), - this.pool.query( - `SELECT name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out - FROM check_network_interfaces WHERE check_id = $1`, - [row.id] - ), - this.pool.query(`SELECT metrics, error FROM check_errors WHERE check_id = $1`, [row.id]), - ]); - disk = diskResult.rows.map((d) => ({ - device: d.device, - mountpoint: d.mountpoint, - total_bytes: Number(d.total_bytes), - free_bytes: Number(d.free_bytes), - used_bytes: Number(d.used_bytes), - usage_percent: d.usage_percent, - total_inodes: Number(d.total_inodes), - free_inodes: Number(d.free_inodes), - used_inodes: Number(d.used_inodes), - inodes_usage_percent: d.inodes_usage_percent, - read_bytes: Number(d.read_bytes), - write_bytes: Number(d.write_bytes), - read_time: Number(d.read_time), - write_time: Number(d.write_time), - })); - net = netResult.rows.map((n) => ({ - name: n.name, - bytes_sent: Number(n.bytes_sent), - bytes_recv: Number(n.bytes_recv), - packets_sent: Number(n.packets_sent), - packets_recv: Number(n.packets_recv), - err_in: Number(n.err_in), - err_out: Number(n.err_out), - drop_in: Number(n.drop_in), - drop_out: Number(n.drop_out), - fifo_in: Number(n.fifo_in), - fifo_out: Number(n.fifo_out), - })); - errors = errorsResult.rows.map((e) => ({ metric: e.metrics ?? [], err: e.error ?? "" })); - } - - return { - id: row.id, - status: row.status, - responseTime: row.response_time ?? 0, - statusCode: row.status_code ?? 0, - message: row.message ?? "", - timings: - row.timing_start !== null - ? { - start: row.timing_start, - socket: row.timing_socket, - lookup: row.timing_lookup, - connect: row.timing_connect, - secureConnect: row.timing_secure_connect, - upload: row.timing_upload, - response: row.timing_response, - end: row.timing_end, - phases: { - wait: row.phase_wait, - dns: row.phase_dns, - tcp: row.phase_tcp, - tls: row.phase_tls, - request: row.phase_request, - firstByte: row.phase_first_byte, - download: row.phase_download, - total: row.phase_total, - }, - } - : undefined, - cpu: - row.cpu_usage_percent !== null - ? { - physical_core: row.cpu_physical_core, - logical_core: row.cpu_logical_core, - frequency: row.cpu_frequency, - current_frequency: row.cpu_current_frequency, - temperature: row.cpu_temperature ?? [], - free_percent: row.cpu_free_percent, - usage_percent: row.cpu_usage_percent, - } - : undefined, - memory: - row.mem_usage_percent !== null - ? { - total_bytes: Number(row.mem_total_bytes), - available_bytes: Number(row.mem_available_bytes), - used_bytes: Number(row.mem_used_bytes), - usage_percent: row.mem_usage_percent, - } - : undefined, - disk, - host: - row.host_os !== null - ? { - os: row.host_os, - platform: row.host_platform, - kernel_version: row.host_kernel_version, - pretty_name: row.host_pretty_name, - } - : undefined, - errors, - capture: - row.capture_version !== null - ? { - version: row.capture_version, - mode: row.capture_mode, - } - : undefined, - net, - performance: row.lighthouse_performance ?? undefined, - accessibility: row.lighthouse_accessibility ?? undefined, - bestPractices: row.lighthouse_best_practices ?? undefined, - seo: row.lighthouse_seo ?? undefined, - audits: - row.audit_cls_score !== null - ? { - cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, - si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, - fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, - lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, - tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, - } - : undefined, - createdAt: row.created_at.toISOString(), - }; - }) - ); - - const maintResult = await this.pool.query( - `SELECT 1 FROM maintenance_windows - WHERE monitor_id = $1 AND active = TRUE AND start_time <= NOW() AND end_time >= NOW() - LIMIT 1`, - [monitor.id] - ); - if ((maintResult.rowCount ?? 0) > 0) { - monitor.status = "maintenance"; - } - - const statsResult = await this.pool.query(`SELECT uptime_percentage FROM monitor_stats WHERE monitor_id = $1`, [monitor.id]); - if (statsResult.rows[0]) { - monitor.uptimePercentage = statsResult.rows[0].uptime_percentage; - } - } - - return monitors; - }; - - findMonitorCountByTeamIdAndType = async (teamId: string, config?: TeamQueryConfig): Promise => { - const { type } = config ?? {}; - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (type !== undefined) { - if (Array.isArray(type)) { - conditions.push(`type = ANY($${paramIndex++})`); - values.push(type); - } else { - conditions.push(`type = $${paramIndex++}`); - values.push(type); - } - } - - const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM monitors WHERE ${conditions.join(" AND ")}`, values); - return result.rows[0].count; - }; - - updateById = async (monitorId: string, teamId: string, patch: Partial): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof Monitor, string][] = [ - ["name", "name"], - ["description", "description"], - ["type", "type"], - ["status", "status"], - ["url", "url"], - ["port", "port"], - ["ignoreTlsErrors", "ignore_tls_errors"], - ["useAdvancedMatching", "use_advanced_matching"], - ["jsonPath", "json_path"], - ["expectedValue", "expected_value"], - ["matchMethod", "match_method"], - ["secret", "secret"], - ["interval", "interval_ms"], - ["isActive", "is_active"], - ["statusWindow", "status_window"], - ["statusWindowSize", "status_window_size"], - ["statusWindowThreshold", "status_window_threshold"], - ["uptimePercentage", "uptime_percentage"], - ["cpuAlertThreshold", "cpu_alert_threshold"], - ["cpuAlertCounter", "cpu_alert_counter"], - ["memoryAlertThreshold", "memory_alert_threshold"], - ["memoryAlertCounter", "memory_alert_counter"], - ["diskAlertThreshold", "disk_alert_threshold"], - ["diskAlertCounter", "disk_alert_counter"], - ["tempAlertThreshold", "temp_alert_threshold"], - ["tempAlertCounter", "temp_alert_counter"], - ["selectedDisks", "selected_disks"], - ["gameId", "game_id"], - ["grpcServiceName", "grpc_service_name"], - ["group", "monitor_group"], - ["geoCheckEnabled", "geo_check_enabled"], - ["geoCheckLocations", "geo_check_locations"], - ["geoCheckInterval", "geo_check_interval_ms"], - ]; - - for (const [key, column] of fieldMap) { - if (patch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - // Empty string matchMethod is stored as NULL (not a valid enum value) - values.push(key === "matchMethod" && patch[key] === "" ? null : patch[key]); - } - } - - if (sets.length === 0) { - return this.findById(monitorId, teamId); - } - - sets.push(`updated_at = NOW()`); - values.push(monitorId, teamId); - - const result = await this.pool.query( - `UPDATE monitors SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${MONITOR_COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 }); - } - - // Update notification associations if provided - if (patch.notifications !== undefined) { - await this.pool.query(`DELETE FROM monitor_notifications WHERE monitor_id = $1`, [monitorId]); - for (const notificationId of patch.notifications) { - await this.pool.query(`INSERT INTO monitor_notifications (monitor_id, notification_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [ - monitorId, - notificationId, - ]); - } - } - - const entity = this.toEntity(row); - entity.notifications = patch.notifications ?? (await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? [])); - return entity; - }; - - updateStatusWindowAndChecks = async ( - monitorId: string, - teamId: string, - status: boolean, - _checkSnapshot: CheckSnapshot, - windowSize: number, - _maxRecentChecks: number, - statusPatch?: Partial - ): Promise => { - // In TimescaleDB, recentChecks live in the checks table (inserted by the buffer service), - // so we only need to atomically update status_window and any statusPatch fields. - const sets: string[] = [ - `status_window = (array_append(COALESCE(status_window, ARRAY[]::boolean[]), $1))[array_length(array_append(COALESCE(status_window, ARRAY[]::boolean[]), $1), 1) - $2 + 1:]`, - `updated_at = NOW()`, - ]; - const values: unknown[] = [status, windowSize]; - let paramIndex = 3; - - if (statusPatch) { - const patchFieldMap: [keyof Monitor, string][] = [ - ["status", "status"], - ["cpuAlertCounter", "cpu_alert_counter"], - ["memoryAlertCounter", "memory_alert_counter"], - ["diskAlertCounter", "disk_alert_counter"], - ["tempAlertCounter", "temp_alert_counter"], - ]; - for (const [key, column] of patchFieldMap) { - if (statusPatch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - values.push(statusPatch[key]); - } - } - } - - values.push(monitorId, teamId); - const result = await this.pool.query( - `UPDATE monitors SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${MONITOR_COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Failed to update status and checks for monitor with id ${monitorId}`, status: 500 }); - } - const entity = this.toEntity(row); - entity.notifications = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); - return entity; - }; - - togglePauseById = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query( - `UPDATE monitors SET - is_active = NOT is_active, - status = CASE WHEN status = 'paused' THEN 'initializing'::monitor_status ELSE 'paused'::monitor_status END, - updated_at = NOW() - WHERE id = $1 AND team_id = $2 - RETURNING ${MONITOR_COLUMNS}`, - [monitorId, teamId] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Monitor with ID ${monitorId} not found for the given team.`, status: 404 }); - } - const entity = this.toEntity(row); - entity.notifications = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); - return entity; - }; - - deleteById = async (monitorId: string, teamId: string): Promise => { - // Fetch notifications before delete (FK cascade will remove join rows) - const notifs = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); - const result = await this.pool.query(`DELETE FROM monitors WHERE id = $1 AND team_id = $2 RETURNING ${MONITOR_COLUMNS}`, [ - monitorId, - teamId, - ]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Monitor with ID ${monitorId} not found for the given team.`, status: 404 }); - } - const entity = this.toEntity(row); - entity.notifications = notifs; - return entity; - }; - - deleteByTeamId = async (teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }> => { - // Fetch notifications before delete - const monitorsResult = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE team_id = $1`, [teamId]); - const monitorIds = monitorsResult.rows.map((r) => r.id); - const notifMap = monitorIds.length > 0 ? await this.fetchNotificationIds(monitorIds) : new Map(); - - const result = await this.pool.query(`DELETE FROM monitors WHERE team_id = $1 RETURNING ${MONITOR_COLUMNS}`, [teamId]); - const monitors = result.rows.map((row) => { - const entity = this.toEntity(row); - entity.notifications = notifMap.get(row.id) ?? []; - return entity; - }); - return { monitors, deletedCount: result.rowCount ?? 0 }; - }; - - findMonitorsSummaryByTeamId = async (teamId: string, config?: SummaryConfig): Promise => { - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (config?.type !== undefined) { - if (Array.isArray(config.type)) { - conditions.push(`type = ANY($${paramIndex++})`); - values.push(config.type); - } else { - conditions.push(`type = $${paramIndex++}`); - values.push(config.type); - } - } - - const result = await this.pool.query( - `SELECT - COUNT(*)::int AS "totalMonitors", - COUNT(*) FILTER (WHERE status = 'up')::int AS "upMonitors", - COUNT(*) FILTER (WHERE status = 'down')::int AS "downMonitors", - COUNT(*) FILTER (WHERE status = 'paused')::int AS "pausedMonitors", - COUNT(*) FILTER (WHERE status = 'initializing')::int AS "initializingMonitors", - COUNT(*) FILTER (WHERE status = 'maintenance')::int AS "maintenanceMonitors", - COUNT(*) FILTER (WHERE status = 'breached')::int AS "breachedMonitors" - FROM monitors WHERE ${conditions.join(" AND ")}`, - values - ); - - return ( - result.rows[0] ?? { - totalMonitors: 0, - upMonitors: 0, - downMonitors: 0, - pausedMonitors: 0, - initializingMonitors: 0, - maintenanceMonitors: 0, - breachedMonitors: 0, - } - ); - }; - - findGroupsByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query( - `SELECT DISTINCT monitor_group FROM monitors - WHERE team_id = $1 AND monitor_group IS NOT NULL AND monitor_group != '' - ORDER BY monitor_group`, - [teamId] - ); - return result.rows.map((row) => row.monitor_group); - }; - - removeNotificationFromMonitors = async (notificationId: string): Promise => { - await this.pool.query(`DELETE FROM monitor_notifications WHERE notification_id = $1`, [notificationId]); - }; - - updateNotifications = async ( - teamId: string, - monitorIds: string[], - notificationIds: string[], - action: "add" | "remove" | "set" - ): Promise => { - if (!monitorIds.length) { - return 0; - } - - // Verify monitors belong to team - const monitorCheck = await this.pool.query(`SELECT id FROM monitors WHERE id = ANY($1) AND team_id = $2`, [monitorIds, teamId]); - const validMonitorIds = monitorCheck.rows.map((row) => row.id); - - if (!validMonitorIds.length) { - return 0; - } - - let modified = 0; - - switch (action) { - case "set": { - // Track which monitors actually change - const existingSet = await this.pool.query(`SELECT monitor_id, notification_id FROM monitor_notifications WHERE monitor_id = ANY($1)`, [ - validMonitorIds, - ]); - const existingByMonitor = new Map>(); - for (const row of existingSet.rows) { - if (!existingByMonitor.has(row.monitor_id)) existingByMonitor.set(row.monitor_id, new Set()); - existingByMonitor.get(row.monitor_id)!.add(row.notification_id); - } - - await this.pool.query(`DELETE FROM monitor_notifications WHERE monitor_id = ANY($1)`, [validMonitorIds]); - for (const monitorId of validMonitorIds) { - const existing = existingByMonitor.get(monitorId); - const newSet = new Set(notificationIds); - const changed = !existing || existing.size !== newSet.size || [...newSet].some((id) => !existing.has(id)); - if (changed) modified++; - - for (const notificationId of notificationIds) { - await this.pool.query( - `INSERT INTO monitor_notifications (monitor_id, notification_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING`, - [monitorId, notificationId] - ); - } - } - break; - } - case "add": { - // Count monitors where at least one new notification was added - const monitorsModified = new Set(); - for (const monitorId of validMonitorIds) { - for (const notificationId of notificationIds) { - const res = await this.pool.query( - `INSERT INTO monitor_notifications (monitor_id, notification_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING`, - [monitorId, notificationId] - ); - if ((res.rowCount ?? 0) > 0) { - monitorsModified.add(monitorId); - } - } - } - modified = monitorsModified.size; - break; - } - case "remove": { - // Count distinct monitors that will be affected before deleting - const affectedResult = await this.pool.query( - `SELECT COUNT(DISTINCT monitor_id)::int AS count FROM monitor_notifications - WHERE monitor_id = ANY($1) AND notification_id = ANY($2)`, - [validMonitorIds, notificationIds] - ); - modified = affectedResult.rows[0]?.count ?? 0; - await this.pool.query( - `DELETE FROM monitor_notifications - WHERE monitor_id = ANY($1) AND notification_id = ANY($2)`, - [validMonitorIds, notificationIds] - ); - break; - } - default: - throw new AppError({ message: `Invalid action: ${action}`, status: 400 }); - } - - return modified; - }; - - deleteByTeamIdsNotIn = async (teamIds: string[]): Promise => { - if (!teamIds.length) { - const result = await this.pool.query(`DELETE FROM monitors`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM monitors WHERE team_id != ALL($1)`, [teamIds]); - return result.rowCount ?? 0; - }; - - findAllMonitorIds = async (): Promise => { - const result = await this.pool.query(`SELECT id FROM monitors`); - return result.rows.map((row) => row.id); - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private toCheckSnapshot = (row: any, childData?: { disk: any[]; net: any[]; errors: any[] }) => ({ - id: row.id, - status: row.status, - responseTime: row.response_time ?? 0, - statusCode: row.status_code ?? 0, - message: row.message ?? "", - timings: - row.timing_start !== null - ? { - start: row.timing_start, - socket: row.timing_socket, - lookup: row.timing_lookup, - connect: row.timing_connect, - secureConnect: row.timing_secure_connect, - upload: row.timing_upload, - response: row.timing_response, - end: row.timing_end, - phases: { - wait: row.phase_wait, - dns: row.phase_dns, - tcp: row.phase_tcp, - tls: row.phase_tls, - request: row.phase_request, - firstByte: row.phase_first_byte, - download: row.phase_download, - total: row.phase_total, - }, - } - : undefined, - cpu: - row.cpu_usage_percent !== null - ? { - physical_core: row.cpu_physical_core, - logical_core: row.cpu_logical_core, - frequency: row.cpu_frequency, - current_frequency: row.cpu_current_frequency, - temperature: row.cpu_temperature ?? [], - free_percent: row.cpu_free_percent, - usage_percent: row.cpu_usage_percent, - } - : undefined, - memory: - row.mem_usage_percent !== null - ? { - total_bytes: Number(row.mem_total_bytes), - available_bytes: Number(row.mem_available_bytes), - used_bytes: Number(row.mem_used_bytes), - usage_percent: row.mem_usage_percent, - } - : undefined, - disk: childData?.disk ?? [], - host: - row.host_os !== null - ? { - os: row.host_os, - platform: row.host_platform, - kernel_version: row.host_kernel_version, - pretty_name: row.host_pretty_name, - } - : undefined, - errors: childData?.errors ?? [], - capture: - row.capture_version !== null - ? { - version: row.capture_version, - mode: row.capture_mode, - } - : undefined, - net: childData?.net ?? [], - performance: row.lighthouse_performance ?? undefined, - accessibility: row.lighthouse_accessibility ?? undefined, - bestPractices: row.lighthouse_best_practices ?? undefined, - seo: row.lighthouse_seo ?? undefined, - audits: - row.audit_cls_score !== null - ? { - cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, - si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, - fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, - lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, - tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, - } - : undefined, - createdAt: row.created_at.toISOString(), - }); - - private fetchNotificationIds = async (monitorIds: string[]): Promise> => { - const result = await this.pool.query(`SELECT monitor_id, notification_id FROM monitor_notifications WHERE monitor_id = ANY($1)`, [monitorIds]); - const map = new Map(); - for (const row of result.rows) { - if (!map.has(row.monitor_id)) map.set(row.monitor_id, []); - map.get(row.monitor_id)!.push(row.notification_id); - } - return map; - }; - - private fetchCheckChildData = async (checkId: string) => { - const [diskResult, netResult, errorsResult] = await Promise.all([ - this.pool.query( - `SELECT device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, - read_bytes, write_bytes, read_time, write_time - FROM check_disks WHERE check_id = $1`, - [checkId] - ), - this.pool.query( - `SELECT name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out - FROM check_network_interfaces WHERE check_id = $1`, - [checkId] - ), - this.pool.query(`SELECT metrics, error FROM check_errors WHERE check_id = $1`, [checkId]), - ]); - return { - disk: diskResult.rows.map((d) => ({ - device: d.device, - mountpoint: d.mountpoint, - total_bytes: Number(d.total_bytes), - free_bytes: Number(d.free_bytes), - used_bytes: Number(d.used_bytes), - usage_percent: d.usage_percent, - total_inodes: Number(d.total_inodes), - free_inodes: Number(d.free_inodes), - used_inodes: Number(d.used_inodes), - inodes_usage_percent: d.inodes_usage_percent, - read_bytes: Number(d.read_bytes), - write_bytes: Number(d.write_bytes), - read_time: Number(d.read_time), - write_time: Number(d.write_time), - })), - net: netResult.rows.map((n) => ({ - name: n.name, - bytes_sent: Number(n.bytes_sent), - bytes_recv: Number(n.bytes_recv), - packets_sent: Number(n.packets_sent), - packets_recv: Number(n.packets_recv), - err_in: Number(n.err_in), - err_out: Number(n.err_out), - drop_in: Number(n.drop_in), - drop_out: Number(n.drop_out), - fifo_in: Number(n.fifo_in), - fifo_out: Number(n.fifo_out), - })), - errors: errorsResult.rows.map((e) => ({ metric: e.metrics ?? [], err: e.error ?? "" })), - }; - }; - - private toEntity = (row: MonitorRow): Monitor => ({ - id: row.id, - userId: row.user_id, - teamId: row.team_id, - name: row.name, - description: row.description ?? undefined, - status: row.status as MonitorStatus, - statusWindow: row.status_window ?? [], - statusWindowSize: row.status_window_size, - statusWindowThreshold: row.status_window_threshold, - type: row.type, - ignoreTlsErrors: row.ignore_tls_errors, - useAdvancedMatching: row.use_advanced_matching, - jsonPath: row.json_path ?? undefined, - expectedValue: row.expected_value ?? undefined, - matchMethod: row.match_method ?? undefined, - url: row.url ?? "", - port: row.port ?? undefined, - isActive: row.is_active, - interval: row.interval_ms, - uptimePercentage: row.uptime_percentage ?? undefined, - notifications: [], - secret: row.secret ?? undefined, - cpuAlertThreshold: row.cpu_alert_threshold, - cpuAlertCounter: row.cpu_alert_counter, - memoryAlertThreshold: row.memory_alert_threshold, - memoryAlertCounter: row.memory_alert_counter, - diskAlertThreshold: row.disk_alert_threshold, - diskAlertCounter: row.disk_alert_counter, - tempAlertThreshold: row.temp_alert_threshold, - tempAlertCounter: row.temp_alert_counter, - selectedDisks: row.selected_disks ?? [], - gameId: row.game_id ?? undefined, - grpcServiceName: row.grpc_service_name ?? undefined, - group: row.monitor_group, - geoCheckEnabled: row.geo_check_enabled, - geoCheckLocations: row.geo_check_locations ?? [], - geoCheckInterval: row.geo_check_interval_ms, - recentChecks: [], - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts deleted file mode 100644 index b266f33f0..000000000 --- a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Pool } from "pg"; -import { INotificationsRepository } from "@/repositories/notifications/INotificationsRepository.js"; -import type { Notification, NotificationChannel } from "@/types/notification.js"; -import { AppError } from "@/utils/AppError.js"; - -interface NotificationRow { - id: string; - user_id: string; - team_id: string; - type: NotificationChannel; - notification_name: string; - address: string | null; - phone: string | null; - homeserver_url: string | null; - room_id: string | null; - access_token: string | null; - account_sid: string | null; - twilio_phone_number: string | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone, - homeserver_url, room_id, access_token, account_sid, twilio_phone_number, created_at, updated_at`; - -export class TimescaleNotificationsRepository implements INotificationsRepository { - constructor(private pool: Pool) {} - - create = async (data: Partial): Promise => { - const result = await this.pool.query( - `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token, account_sid, twilio_phone_number) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING ${COLUMNS}`, - [ - data.userId, - data.teamId, - data.type, - data.notificationName, - data.address ?? null, - data.phone ?? null, - data.homeserverUrl ?? null, - data.roomId ?? null, - data.accessToken ?? null, - data.accountSid ?? null, - data.twilioPhoneNumber ?? null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create notification", status: 500 }); - } - return this.toEntity(row); - }; - - findById = async (id: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM notifications WHERE id = $1 AND team_id = $2`, [id, teamId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Notification not found", status: 404 }); - } - return this.toEntity(row); - }; - - findNotificationsByIds = async (ids: string[]): Promise => { - if (!ids.length) { - return []; - } - const result = await this.pool.query(`SELECT ${COLUMNS} FROM notifications WHERE id = ANY($1)`, [ids]); - return result.rows.map(this.toEntity); - }; - - findByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM notifications WHERE team_id = $1`, [teamId]); - return result.rows.map(this.toEntity); - }; - - updateById = async (id: string, teamId: string, patch: Partial): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof Notification, string][] = [ - ["type", "type"], - ["notificationName", "notification_name"], - ["address", "address"], - ["phone", "phone"], - ["homeserverUrl", "homeserver_url"], - ["roomId", "room_id"], - ["accessToken", "access_token"], - ["accountSid", "account_sid"], - ["twilioPhoneNumber", "twilio_phone_number"], - ]; - - for (const [key, column] of fieldMap) { - if (patch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - values.push(patch[key]); - } - } - - if (sets.length === 0) { - return this.findById(id, teamId); - } - - sets.push(`updated_at = NOW()`); - values.push(id, teamId); - - const result = await this.pool.query( - `UPDATE notifications SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Notification not found or could not be updated", status: 404 }); - } - return this.toEntity(row); - }; - - deleteById = async (id: string, teamId: string): Promise => { - const result = await this.pool.query(`DELETE FROM notifications WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`, [ - id, - teamId, - ]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Notification not found or could not be deleted", status: 404 }); - } - return this.toEntity(row); - }; - - private toEntity = (row: NotificationRow): Notification => ({ - id: row.id, - userId: row.user_id, - teamId: row.team_id, - type: row.type, - notificationName: row.notification_name, - address: row.address ?? undefined, - phone: row.phone ?? undefined, - homeserverUrl: row.homeserver_url ?? undefined, - roomId: row.room_id ?? undefined, - accessToken: row.access_token ?? undefined, - accountSid: row.account_sid ?? undefined, - twilioPhoneNumber: row.twilio_phone_number ?? undefined, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts b/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts deleted file mode 100644 index 4c5f0fe6a..000000000 --- a/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Pool } from "pg"; -import crypto from "crypto"; -import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js"; -import type { RecoveryToken } from "@/types/recoveryToken.js"; -import { AppError } from "@/utils/AppError.js"; - -interface RecoveryTokenRow { - id: string; - email: string; - token: string; - expiry: Date | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, email, token, expiry, created_at, updated_at`; - -export class TimescaleRecoveryTokensRepository implements IRecoveryTokensRepository { - constructor(private pool: Pool) {} - - create = async (email: string): Promise => { - const token = crypto.randomBytes(32).toString("hex"); - const result = await this.pool.query( - `INSERT INTO recovery_tokens (email, token) - VALUES ($1, $2) - ON CONFLICT (email) DO UPDATE SET token = $2, updated_at = NOW() - RETURNING ${COLUMNS}`, - [email, token] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create recovery token", status: 500 }); - } - return this.toEntity(row); - }; - - findByToken = async (token: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM recovery_tokens WHERE token = $1`, [token]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Recovery token not found", status: 404 }); - } - return this.toEntity(row); - }; - - deleteManyByEmail = async (email: string): Promise => { - const result = await this.pool.query(`DELETE FROM recovery_tokens WHERE email = $1`, [email]); - return result.rowCount ?? 0; - }; - - private toEntity = (row: RecoveryTokenRow): RecoveryToken => ({ - id: row.id, - email: row.email, - token: row.token, - expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(), - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/settings/TimescaleSettingsRepository.ts b/server/src/repositories/settings/TimescaleSettingsRepository.ts deleted file mode 100644 index 82038b23f..000000000 --- a/server/src/repositories/settings/TimescaleSettingsRepository.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { Pool } from "pg"; -import type { ISettingsRepository } from "./ISettingsRepository.js"; -import type { Settings, SettingsUpdate } from "@/types/settings.js"; - -interface SettingsRow { - id: string; - check_ttl: number; - language: string | null; - jwt_secret: string | null; - pagespeed_api_key: string | null; - system_email_host: string | null; - system_email_port: number | null; - system_email_address: string | null; - system_email_password: string | null; - system_email_user: string | null; - system_email_connection_host: string | null; - system_email_tls_servername: string | null; - system_email_secure: boolean | null; - system_email_pool: boolean | null; - system_email_ignore_tls: boolean | null; - system_email_require_tls: boolean | null; - system_email_reject_unauthorized: boolean | null; - show_url: boolean | null; - version: string | null; - threshold_cpu_usage: number | null; - threshold_memory_usage: number | null; - threshold_disk_usage: number | null; - threshold_temperature: number | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key, - system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, - system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, - system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, - show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, - created_at, updated_at`; - -export class TimescaleSettingsRepository implements ISettingsRepository { - constructor(private pool: Pool) {} - - create = async (settings: Partial): Promise => { - const result = await this.pool.query( - `INSERT INTO app_settings (check_ttl, language, jwt_secret, pagespeed_api_key, - system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, - system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, - system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, - show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) - RETURNING ${COLUMNS}`, - [ - settings.checkTTL ?? 30, - settings.language ?? null, - settings.jwtSecret ?? null, - settings.pagespeedApiKey ?? null, - settings.systemEmailHost ?? null, - settings.systemEmailPort ?? null, - settings.systemEmailAddress ?? null, - settings.systemEmailPassword ?? null, - settings.systemEmailUser ?? null, - settings.systemEmailConnectionHost ?? null, - settings.systemEmailTLSServername ?? null, - settings.systemEmailSecure ?? false, - settings.systemEmailPool ?? false, - settings.systemEmailIgnoreTLS ?? false, - settings.systemEmailRequireTLS ?? false, - settings.systemEmailRejectUnauthorized ?? true, - settings.showURL ?? false, - settings.version ?? 1, - settings.globalThresholds?.cpu ?? null, - settings.globalThresholds?.memory ?? null, - settings.globalThresholds?.disk ?? null, - settings.globalThresholds?.temperature ?? null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new Error("Failed to create app settings"); - } - return this.toEntity(row); - }; - - findSingleton = async (): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM app_settings LIMIT 1`); - const row = result.rows[0]; - if (!row) { - return null; - } - return this.toEntity(row); - }; - - update = async (settings: SettingsUpdate): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof SettingsUpdate, string][] = [ - ["checkTTL", "check_ttl"], - ["language", "language"], - ["jwtSecret", "jwt_secret"], - ["pagespeedApiKey", "pagespeed_api_key"], - ["systemEmailHost", "system_email_host"], - ["systemEmailPort", "system_email_port"], - ["systemEmailAddress", "system_email_address"], - ["systemEmailPassword", "system_email_password"], - ["systemEmailUser", "system_email_user"], - ["systemEmailConnectionHost", "system_email_connection_host"], - ["systemEmailTLSServername", "system_email_tls_servername"], - ["systemEmailSecure", "system_email_secure"], - ["systemEmailPool", "system_email_pool"], - ["systemEmailIgnoreTLS", "system_email_ignore_tls"], - ["systemEmailRequireTLS", "system_email_require_tls"], - ["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"], - ["showURL", "show_url"], - ["version", "version"], - ]; - - for (const [key, column] of fieldMap) { - if (key in settings) { - sets.push(`${column} = $${paramIndex++}`); - // null means unset the field - values.push(settings[key] ?? null); - } - } - - // Handle globalThresholds - if ("globalThresholds" in settings) { - const thresholds = settings.globalThresholds; - if (thresholds === null || thresholds === undefined) { - sets.push(`threshold_cpu_usage = NULL`); - sets.push(`threshold_memory_usage = NULL`); - sets.push(`threshold_disk_usage = NULL`); - sets.push(`threshold_temperature = NULL`); - } else { - if ("cpu" in thresholds) { - sets.push(`threshold_cpu_usage = $${paramIndex++}`); - values.push(thresholds.cpu ?? null); - } - if ("memory" in thresholds) { - sets.push(`threshold_memory_usage = $${paramIndex++}`); - values.push(thresholds.memory ?? null); - } - if ("disk" in thresholds) { - sets.push(`threshold_disk_usage = $${paramIndex++}`); - values.push(thresholds.disk ?? null); - } - if ("temperature" in thresholds) { - sets.push(`threshold_temperature = $${paramIndex++}`); - values.push(thresholds.temperature ?? null); - } - } - } - - if (sets.length === 0) { - const existing = await this.findSingleton(); - if (!existing) { - throw new Error("App settings not found"); - } - return existing; - } - - sets.push(`updated_at = NOW()`); - - // Upsert: update the single row, or insert if none exists - const result = await this.pool.query(`UPDATE app_settings SET ${sets.join(", ")} RETURNING ${COLUMNS}`, values); - const row = result.rows[0]; - if (!row) { - // No row existed, create one - return this.create(settings as Partial); - } - return this.toEntity(row); - }; - - deleteLegacy = async (): Promise => { - // No legacy rows in TimescaleDB — singleton is enforced by trigger - const result = await this.pool.query(`DELETE FROM app_settings WHERE version IS NULL`); - return (result.rowCount ?? 0) > 0; - }; - - private toEntity = (row: SettingsRow): Settings => ({ - id: row.id, - checkTTL: row.check_ttl, - language: row.language ?? "", - jwtSecret: row.jwt_secret ?? undefined, - pagespeedApiKey: row.pagespeed_api_key ?? undefined, - systemEmailHost: row.system_email_host ?? undefined, - systemEmailPort: row.system_email_port ?? undefined, - systemEmailAddress: row.system_email_address ?? undefined, - systemEmailPassword: row.system_email_password ?? undefined, - systemEmailUser: row.system_email_user ?? undefined, - systemEmailConnectionHost: row.system_email_connection_host ?? undefined, - systemEmailTLSServername: row.system_email_tls_servername ?? undefined, - systemEmailSecure: row.system_email_secure ?? false, - systemEmailPool: row.system_email_pool ?? false, - systemEmailIgnoreTLS: row.system_email_ignore_tls ?? false, - systemEmailRequireTLS: row.system_email_require_tls ?? false, - systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true, - showURL: row.show_url ?? false, - singleton: true, - version: Number(row.version ?? 1), - globalThresholds: - row.threshold_cpu_usage !== null || - row.threshold_memory_usage !== null || - row.threshold_disk_usage !== null || - row.threshold_temperature !== null - ? { - cpu: row.threshold_cpu_usage ?? undefined, - memory: row.threshold_memory_usage ?? undefined, - disk: row.threshold_disk_usage ?? undefined, - temperature: row.threshold_temperature ?? undefined, - } - : undefined, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts b/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts deleted file mode 100644 index 583e775b5..000000000 --- a/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { Pool } from "pg"; -import type { IStatusPagesRepository } from "./IStatusPagesRepository.js"; -import type { StatusPage, StatusPageType } from "@/types/statusPage.js"; -import { AppError } from "@/utils/AppError.js"; - -const parsePostgresArray = (value: unknown): StatusPageType[] => { - if (typeof value !== "string") return []; - const inner = value.replace(/^\{|\}$/g, ""); - if (inner === "") return []; - return inner.split(",") as StatusPageType[]; -}; - -interface StatusPageRow { - id: string; - user_id: string; - team_id: string; - types: StatusPageType[]; - company_name: string; - url: string; - timezone: string | null; - color: string; - logo_data: Buffer | null; - logo_content_type: string | null; - is_published: boolean; - show_charts: boolean; - show_uptime_percentage: boolean; - show_admin_login_link: boolean; - show_infrastructure: boolean; - custom_css: string | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color, - logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage, - show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`; - -export class TimescaleStatusPagesRepository implements IStatusPagesRepository { - constructor(private pool: Pool) {} - - create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - const result = await this.pool.query( - `INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color, - logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage, - show_admin_login_link, show_infrastructure, custom_css) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) - RETURNING ${COLUMNS}`, - [ - userId, - teamId, - data.type ?? ["uptime"], - data.companyName ?? "", - data.url ?? "", - data.timezone ?? null, - data.color ?? "#4169E1", - image?.buffer ?? null, - image?.mimetype ?? null, - data.isPublished ?? false, - data.showCharts ?? false, - data.showUptimePercentage ?? false, - data.showAdminLoginLink ?? false, - data.showInfrastructure ?? false, - data.customCSS ?? null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create status page", status: 500 }); - } - - // Insert monitor associations - if (data.monitors?.length) { - for (const [i, monitorId] of data.monitors.entries()) { - await this.pool.query( - `INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [row.id, monitorId, i] - ); - } - } - if (data.subMonitors?.length) { - for (const [i, monitorId] of data.subMonitors.entries()) { - await this.pool.query( - `INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [row.id, monitorId, i] - ); - } - } - - return this.loadEntity(row); - }; - - findByUrl = async (url: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM status_pages WHERE url = $1`, [url]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Status page not found", status: 404 }); - } - return this.loadEntity(row); - }; - - findByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM status_pages WHERE team_id = $1`, [teamId]); - const pages: StatusPage[] = []; - for (const row of result.rows) { - pages.push(await this.loadEntity(row)); - } - return pages; - }; - - updateById = async ( - id: string, - teamId: string, - image: Express.Multer.File | undefined, - patch: Partial & { removeLogo?: string } - ): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof StatusPage, string][] = [ - ["type", "types"], - ["companyName", "company_name"], - ["url", "url"], - ["timezone", "timezone"], - ["color", "color"], - ["isPublished", "is_published"], - ["showCharts", "show_charts"], - ["showUptimePercentage", "show_uptime_percentage"], - ["showAdminLoginLink", "show_admin_login_link"], - ["showInfrastructure", "show_infrastructure"], - ["customCSS", "custom_css"], - ]; - - for (const [key, column] of fieldMap) { - if (patch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - values.push(patch[key]); - } - } - - if (image) { - sets.push(`logo_data = $${paramIndex++}`); - values.push(image.buffer); - sets.push(`logo_content_type = $${paramIndex++}`); - values.push(image.mimetype); - } else if (patch.removeLogo === "true") { - sets.push(`logo_data = NULL`); - sets.push(`logo_content_type = NULL`); - } - - if (sets.length > 0) { - sets.push(`updated_at = NOW()`); - values.push(id, teamId); - - const result = await this.pool.query( - `UPDATE status_pages SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Status page not found", status: 404 }); - } - } - - // Update monitor associations if provided - if (patch.monitors !== undefined) { - await this.pool.query(`DELETE FROM status_page_monitors WHERE status_page_id = $1`, [id]); - for (const [i, monitorId] of patch.monitors.entries()) { - await this.pool.query( - `INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [id, monitorId, i] - ); - } - } - if (patch.subMonitors !== undefined) { - await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE status_page_id = $1`, [id]); - for (const [i, monitorId] of patch.subMonitors.entries()) { - await this.pool.query( - `INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [id, monitorId, i] - ); - } - } - - // Re-fetch to return full entity - const freshResult = await this.pool.query(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]); - const freshRow = freshResult.rows[0]; - if (!freshRow) { - throw new AppError({ message: "Status page not found", status: 404 }); - } - return this.loadEntity(freshRow); - }; - - deleteById = async (id: string, teamId: string): Promise => { - // Load entity before delete (join tables cascade) - const existing = await this.pool.query(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]); - const row = existing.rows[0]; - if (!row) { - throw new AppError({ message: "Status page not found", status: 404 }); - } - const entity = await this.loadEntity(row); - - await this.pool.query(`DELETE FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]); - return entity; - }; - - removeMonitorFromStatusPages = async (monitorId: string): Promise => { - // Return count of distinct status pages affected, matching Mongo's modifiedCount - const result = await this.pool.query( - `SELECT COUNT(DISTINCT status_page_id)::int AS count FROM ( - SELECT status_page_id FROM status_page_monitors WHERE monitor_id = $1 - UNION - SELECT status_page_id FROM status_page_sub_monitors WHERE monitor_id = $1 - ) affected`, - [monitorId] - ); - const affectedCount = result.rows[0]?.count ?? 0; - - await this.pool.query(`DELETE FROM status_page_monitors WHERE monitor_id = $1`, [monitorId]); - await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE monitor_id = $1`, [monitorId]); - - return affectedCount; - }; - - private loadEntity = async (row: StatusPageRow): Promise => { - const [monitorsResult, subMonitorsResult] = await Promise.all([ - this.pool.query(`SELECT monitor_id FROM status_page_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]), - this.pool.query(`SELECT monitor_id FROM status_page_sub_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]), - ]); - - return { - id: row.id, - userId: row.user_id, - teamId: row.team_id, - type: Array.isArray(row.types) ? row.types : parsePostgresArray(row.types), - companyName: row.company_name, - url: row.url, - timezone: row.timezone ?? undefined, - color: row.color, - monitors: monitorsResult.rows.map((r) => r.monitor_id), - subMonitors: subMonitorsResult.rows.map((r) => r.monitor_id), - originalMonitors: [], - logo: row.logo_data - ? { - data: row.logo_data.toString("base64"), - contentType: row.logo_content_type ?? "", - } - : undefined, - isPublished: row.is_published, - showCharts: row.show_charts, - showUptimePercentage: row.show_uptime_percentage, - showAdminLoginLink: row.show_admin_login_link, - showInfrastructure: row.show_infrastructure, - customCSS: row.custom_css ?? "", - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }; - }; -} diff --git a/server/src/repositories/teams/TimescaleTeamsRepository.ts b/server/src/repositories/teams/TimescaleTeamsRepository.ts deleted file mode 100644 index be16b7790..000000000 --- a/server/src/repositories/teams/TimescaleTeamsRepository.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Pool } from "pg"; -import { ITeamsRepository } from "@/repositories/teams/ITeamsRepository.js"; -import { Team } from "@/types/team.js"; - -export class TimescaleTeamsRepository implements ITeamsRepository { - constructor(private pool: Pool) {} - - create = async (email: string): Promise => { - const result = await this.pool.query(`INSERT INTO teams (email) VALUES ($1) RETURNING id, email, created_at, updated_at`, [email]); - return this.toEntity(result.rows[0]); - }; - - findAllTeamIds = async (): Promise => { - const result = await this.pool.query(`SELECT id FROM teams`); - return result.rows.map((row) => row.id); - }; - - private toEntity = (row: { id: string; email: string; created_at: Date; updated_at: Date }): Team => ({ - id: row.id, - email: row.email, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/users/TimescaleUsersRepository.ts b/server/src/repositories/users/TimescaleUsersRepository.ts deleted file mode 100644 index 5d7da51b8..000000000 --- a/server/src/repositories/users/TimescaleUsersRepository.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { Pool } from "pg"; -import { IUsersRepository } from "./IUsersRepository.js"; -import type { User, UserRole } from "@/types/user.js"; -import { GenerateAvatarImage } from "@/utils/imageProcessing.js"; -import { ParseBoolean } from "@/utils/utils.js"; -import { AppError } from "@/utils/AppError.js"; - -const SERVICE_NAME = "TimescaleUsersRepository"; - -// pg returns custom enum arrays as strings like "{user,admin}" — parse to JS array -const parsePostgresArray = (value: unknown): UserRole[] => { - if (typeof value !== "string") return []; - const inner = value.replace(/^\{|\}$/g, ""); - if (inner === "") return []; - return inner.split(",") as UserRole[]; -}; - -interface UserRow { - id: string; - team_id: string; - first_name: string; - last_name: string; - email: string; - password: string; - avatar_image: string | null; - profile_image: Buffer | null; - profile_image_content_type: string | null; - is_active: boolean; - is_verified: boolean; - roles: UserRole[]; - check_ttl: number | null; - created_at: Date; - updated_at: Date; -} - -export class TimescaleUsersRepository implements IUsersRepository { - constructor(private pool: Pool) {} - - create = async (user: Partial, imageFile?: Express.Multer.File | null): Promise => { - let avatarImage: string | undefined; - let profileImage: Buffer | undefined; - let profileImageContentType: string | undefined; - - if (imageFile) { - profileImage = imageFile.buffer; - profileImageContentType = imageFile.mimetype; - avatarImage = await GenerateAvatarImage(imageFile); - } - - const result = await this.pool.query( - `INSERT INTO users (team_id, first_name, last_name, email, password, avatar_image, profile_image, profile_image_content_type, is_active, is_verified, roles, check_ttl) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`, - [ - user.teamId || null, - user.firstName, - user.lastName, - user.email, - user.password, - avatarImage || null, - profileImage || null, - profileImageContentType || null, - user.isActive ?? true, - user.isVerified ?? false, - user.role ?? ["user"], - user.checkTTL ?? null, - ] - ); - - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 }); - } - const entity = this.toEntity(row); - entity.password = ""; - return entity; - }; - - findByEmail = async (email: string): Promise => { - const result = await this.pool.query( - `SELECT id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at - FROM users WHERE email = $1`, - [email] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); - } - return this.toEntity(row); - }; - - findById = async (id: string): Promise => { - const result = await this.pool.query( - `SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at - FROM users WHERE id = $1`, - [id] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); - } - return this.toEntity(row); - }; - - findAll = async (): Promise => { - const result = await this.pool.query( - `SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at - FROM users` - ); - return result.rows.map(this.toEntity); - }; - - updateById = async (id: string, patch: Partial, file?: Express.Multer.File | null): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - if (ParseBoolean(patch.deleteProfileImage) === true) { - sets.push(`profile_image = NULL`); - sets.push(`profile_image_content_type = NULL`); - sets.push(`avatar_image = NULL`); - } else if (file) { - const avatarImage = await GenerateAvatarImage(file); - sets.push(`profile_image = $${paramIndex++}`); - values.push(file.buffer); - sets.push(`profile_image_content_type = $${paramIndex++}`); - values.push(file.mimetype); - sets.push(`avatar_image = $${paramIndex++}`); - values.push(avatarImage); - } - - if (patch.firstName !== undefined) { - sets.push(`first_name = $${paramIndex++}`); - values.push(patch.firstName); - } - if (patch.lastName !== undefined) { - sets.push(`last_name = $${paramIndex++}`); - values.push(patch.lastName); - } - if (patch.email !== undefined) { - sets.push(`email = $${paramIndex++}`); - values.push(patch.email); - } - if (patch.password !== undefined) { - sets.push(`password = $${paramIndex++}`); - values.push(patch.password); - } - if (patch.isActive !== undefined) { - sets.push(`is_active = $${paramIndex++}`); - values.push(patch.isActive); - } - if (patch.isVerified !== undefined) { - sets.push(`is_verified = $${paramIndex++}`); - values.push(patch.isVerified); - } - if (patch.role !== undefined) { - sets.push(`roles = $${paramIndex++}`); - values.push(patch.role); - } - if (patch.teamId !== undefined) { - sets.push(`team_id = $${paramIndex++}`); - values.push(patch.teamId); - } - if (patch.checkTTL !== undefined) { - sets.push(`check_ttl = $${paramIndex++}`); - values.push(patch.checkTTL); - } - - if (sets.length === 0) { - return this.findById(id); - } - - sets.push(`updated_at = NOW()`); - values.push(id); - - const result = await this.pool.query( - `UPDATE users SET ${sets.join(", ")} WHERE id = $${paramIndex} - RETURNING id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); - } - return this.toEntity(row); - }; - - deleteById = async (id: string): Promise => { - const result = await this.pool.query( - `DELETE FROM users WHERE id = $1 - RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`, - [id] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); - } - return this.toEntity(row); - }; - - findSuperAdmin = async (): Promise => { - const result = await this.pool.query(`SELECT 1 FROM users WHERE 'superadmin' = ANY(roles) LIMIT 1`); - return (result.rowCount ?? 0) > 0; - }; - - private toEntity = (row: UserRow): User => ({ - id: row.id, - firstName: row.first_name, - lastName: row.last_name, - email: row.email, - password: row.password, - avatarImage: row.avatar_image ?? undefined, - profileImage: undefined, - isActive: row.is_active, - isVerified: row.is_verified, - role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles), - teamId: row.team_id ?? "", - checkTTL: row.check_ttl ?? undefined, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index bcedfbe89..eb8452866 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -1,4 +1,4 @@ -export const DbTypes = ["timescaledb", "mongodb"] as const; +export const DbTypes = ["mongodb"] as const; export type DbType = (typeof DbTypes)[number]; export interface SettingsThresholds { From 3e947ca2df07ef6a4617d1075b7e34a77597221e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 20 Apr 2026 11:03:13 -0700 Subject: [PATCH 5/7] remove bullmq, pg, bump dependencies --- server/package-lock.json | 287 ++++++--------------------------------- server/package.json | 6 +- 2 files changed, 42 insertions(+), 251 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 1b9061d0c..d9f73f3f2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,7 +14,6 @@ "@pulsecron/pulse": "1.6.8", "axios": "^1.7.2", "bcryptjs": "3.0.2", - "bullmq": "5.41.2", "cacheable-lookup": "7.0.0", "compression": "1.8.1", "cookie-parser": "^1.4.7", @@ -22,7 +21,7 @@ "dockerode": "4.0.6", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "8.0.1", + "express-rate-limit": "8.3.2", "gamedig": "5.2.0", "got": "14.4.7", "handlebars": "^4.7.8", @@ -36,8 +35,7 @@ "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", "multer": "^1.4.5-lts.1", - "nodemailer": "8.0.1", - "pg": "8.20.0", + "nodemailer": "8.0.5", "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", @@ -3169,84 +3167,6 @@ "sparse-bitfield": "^3.0.3" } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -5534,14 +5454,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-jest": { @@ -6007,21 +5927,6 @@ "node": ">=10.0.0" } }, - "node_modules/bullmq": { - "version": "5.41.2", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.41.2.tgz", - "integrity": "sha512-wqsUIHW2Td86mTKTepqQpKLLUtP4gmX89bUO1YL2fAorxwj3da1GYtroGZMCg/zgB/+zMRsbylL6DHyMUWX7fA==", - "license": "MIT", - "dependencies": { - "cron-parser": "^4.9.0", - "ioredis": "^5.4.1", - "msgpackr": "^1.11.2", - "node-abort-controller": "^3.1.1", - "semver": "^7.5.4", - "tslib": "^2.0.0", - "uuid": "^9.0.0" - } - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -7270,13 +7175,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -7997,12 +7899,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.1.tgz", - "integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -8275,9 +8177,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -9194,9 +9096,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -11688,37 +11590,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/msgpackr": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", - "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, "node_modules/multer": { "version": "1.4.5-lts.2", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", @@ -11828,12 +11699,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "license": "MIT" - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -11896,21 +11761,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -11925,9 +11775,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -12359,75 +12209,28 @@ "node": ">=8" } }, - "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", - "license": "MIT" - }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, "license": "ISC", "engines": { "node": ">=4.0.0" } }, - "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, "node_modules/pg-protocol": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, "license": "MIT", "dependencies": { "pg-int8": "1.0.1", @@ -12440,15 +12243,6 @@ "node": ">=4" } }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13078,6 +12872,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -13087,6 +12882,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13096,6 +12892,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13105,6 +12902,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, "license": "MIT", "dependencies": { "xtend": "^4.0.0" @@ -13293,9 +13091,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -13330,10 +13128,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pstree.remy": { "version": "1.1.8", @@ -14158,15 +13959,6 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", "license": "ISC" }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -14912,6 +14704,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, "license": "0BSD" }, "node_modules/tsx": { diff --git a/server/package.json b/server/package.json index d24e21864..4e3f80687 100755 --- a/server/package.json +++ b/server/package.json @@ -32,7 +32,6 @@ "@pulsecron/pulse": "1.6.8", "axios": "^1.7.2", "bcryptjs": "3.0.2", - "bullmq": "5.41.2", "cacheable-lookup": "7.0.0", "compression": "1.8.1", "cookie-parser": "^1.4.7", @@ -40,7 +39,7 @@ "dockerode": "4.0.6", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "8.0.1", + "express-rate-limit": "8.3.2", "gamedig": "5.2.0", "got": "14.4.7", "handlebars": "^4.7.8", @@ -54,8 +53,7 @@ "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", "multer": "^1.4.5-lts.1", - "nodemailer": "8.0.1", - "pg": "8.20.0", + "nodemailer": "8.0.5", "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", From ff0864508796f86942da22692dd2cbf25bacc127 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 20 Apr 2026 11:04:43 -0700 Subject: [PATCH 6/7] remove pool references, update docs --- .github/pull_request_template.md | 12 ++++++------ .gitignore | 4 +--- server/src/db/IDb.ts | 3 --- server/src/db/MongoDB.ts | 5 ----- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 95b5bacbe..9c9ba0c65 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,28 +1,28 @@ -**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)** +**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)** ## Describe your changes -Briefly describe the changes you made and their purpose. +Briefly describe the changes you made and their purpose. ## Write your issue number after "Fixes " -Fixes #123 +Fixes #123 ## Please ensure all items are checked off before requesting a review. "Checked off" means you need to add an "x" character between brackets so they turn into checkmarks. - [ ] (Do not skip this or your PR will be closed) I deployed the application locally. - [ ] (Do not skip this or your PR will be closed) I have performed a self-reviewing and testing of my code. - [ ] I have included the issue # in the PR. -- [ ] I have added i18n support to visible strings (instead of `
Add
`, use): +- [ ] I have added i18n support to visible strings (instead of `
Add
`, use): + ```Javascript const { t } = useTranslation();
{t('add')}
``` + - [ ] I have **not** included any files that are not related to my pull request, including package-lock and package-json if dependencies have not changed - [ ] I didn't use any hardcoded values (otherwise it will not scale, and will make it difficult to maintain consistency across the application). - [ ] I made sure font sizes, color choices etc are all referenced from the theme. I don't have any hardcoded dimensions. - [ ] My PR is granular and targeted to one specific feature. -- [ ] There are two database implementations (MongoDB and PostgreSQL TimescaleDB) and I have taken care of them appropriately. - [ ] I ran `npm run format` in server and client directories, which automatically formats your code. - [ ] I took a screenshot or a video and attached to this PR if there is a UI change. - diff --git a/.gitignore b/.gitignore index 055e3d31e..ff828c1ec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,8 @@ .VSCodeCounter *.sh mongo -timescaledb node_modules/ docs/architecture docs/reviews docs/todo -docs/frontend -docs/timescale \ No newline at end of file +docs/frontend \ No newline at end of file diff --git a/server/src/db/IDb.ts b/server/src/db/IDb.ts index e71e36cca..4c719999f 100644 --- a/server/src/db/IDb.ts +++ b/server/src/db/IDb.ts @@ -1,7 +1,4 @@ -import { Pool } from "pg"; - export interface IDb { connect(): Promise; disconnect(): Promise; - getPool(): Pool | null; } diff --git a/server/src/db/MongoDB.ts b/server/src/db/MongoDB.ts index 59d2a32b7..1284aef4d 100755 --- a/server/src/db/MongoDB.ts +++ b/server/src/db/MongoDB.ts @@ -5,7 +5,6 @@ import { ILogger } from "@/utils/logger.js"; import { EnvConfig } from "@/service/system/settingsService.js"; const SERVICE_NAME = "MongoDB"; import { IDb } from "@/db/IDb.js"; -import { Pool } from "pg"; class MongoDB implements IDb { static SERVICE_NAME = SERVICE_NAME; @@ -79,10 +78,6 @@ class MongoDB implements IDb { }); } }; - - getPool = () => { - return null; - }; } export default MongoDB; From 62c7e20726396e614de93768e49a82fa974af10f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 20 Apr 2026 11:06:56 -0700 Subject: [PATCH 7/7] purge pg types --- server/package-lock.json | 90 ---------------------------------------- server/package.json | 1 - 2 files changed, 91 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index d9f73f3f2..8423aa8d0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -61,7 +61,6 @@ "@types/multer": "^2.0.0", "@types/nodemailer": "7.0.1", "@types/papaparse": "^5.5.2", - "@types/pg": "8.20.0", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", "@types/ws": "^8.18.1", @@ -4418,18 +4417,6 @@ "@types/node": "*" } }, - "node_modules/@types/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/ping": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@types/ping/-/ping-0.4.4.tgz", @@ -12209,40 +12196,6 @@ "node": ">=8" } }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12868,49 +12821,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/posthtml": { "version": "0.16.7", "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz", diff --git a/server/package.json b/server/package.json index 4e3f80687..4251a2f9d 100755 --- a/server/package.json +++ b/server/package.json @@ -79,7 +79,6 @@ "@types/multer": "^2.0.0", "@types/nodemailer": "7.0.1", "@types/papaparse": "^5.5.2", - "@types/pg": "8.20.0", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", "@types/ws": "^8.18.1",