diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 4122fb5b5..d0bd05fb5 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -16,11 +16,13 @@ import { domains, orgs, targets, + roles, users, exitNodes, sessions, clients, siteResources, + targetHealthCheck, sites } from "./schema"; @@ -425,7 +427,9 @@ export const eventStreamingDestinations = pgTable( orgId: varchar("orgId", { length: 255 }) .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false), + sendConnectionLogs: boolean("sendConnectionLogs") + .notNull() + .default(false), sendRequestLogs: boolean("sendRequestLogs").notNull().default(false), sendActionLogs: boolean("sendActionLogs").notNull().default(false), sendAccessLogs: boolean("sendAccessLogs").notNull().default(false), @@ -447,7 +451,9 @@ export const eventStreamingCursors = pgTable( onDelete: "cascade" }), logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection" - lastSentId: bigint("lastSentId", { mode: "number" }).notNull().default(0), + lastSentId: bigint("lastSentId", { mode: "number" }) + .notNull() + .default(0), lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent }, (table) => [ @@ -458,6 +464,66 @@ export const eventStreamingCursors = pgTable( ] ); +export const alertRules = pgTable("alertRules", { + alertRuleId: serial("alertRuleId").primaryKey(), + orgId: varchar("orgId", { length: 255 }) + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + // Single field encodes both source and trigger - no redundancy + eventType: varchar("eventType", { length: 100 }) + .$type< + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy" + >() + .notNull(), + // Nullable depending on eventType + siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId").references( + () => targetHealthCheck.targetHealthCheckId, + { onDelete: "cascade" } + ), + enabled: boolean("enabled").notNull().default(true), + cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() +}); + +// Separating channels by type avoids the mixed-shape problem entirely +export const alertEmailActions = pgTable("alertEmailActions", { + emailActionId: serial("emailActionId").primaryKey(), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + enabled: boolean("enabled").notNull().default(true), + lastSentAt: bigint("lastSentAt", { mode: "number" }), // nullable +}); + +export const alertEmailRecipients = pgTable("alertEmailRecipients", { + recipientId: serial("recipientId").primaryKey(), + emailActionId: integer("emailActionId") + .notNull() + .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), + // At least one of these should be set - enforced at app level + userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), + roleId: varchar("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + email: varchar("email", { length: 255 }) // external emails not tied to a user +}); + +export const alertWebhookActions = pgTable("alertWebhookActions", { + webhookActionId: serial("webhookActionId").primaryKey(), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + webhookUrl: text("webhookUrl").notNull(), + secret: varchar("secret", { length: 255 }), // for HMAC signature validation + enabled: boolean("enabled").notNull().default(true), + lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index c1aa084a2..180a337c1 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -13,9 +13,11 @@ import { domains, exitNodes, orgs, + roles, sessions, siteResources, sites, + targetHealthCheck, users } from "./schema"; @@ -455,6 +457,62 @@ export const eventStreamingCursors = sqliteTable( ] ); +export const alertRules = sqliteTable("alertRules", { + alertRuleId: integer("alertRuleId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: text("name").notNull(), + eventType: text("eventType") + .$type< + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy" + >() + .notNull(), + siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }), + healthCheckId: integer("healthCheckId").references( + () => targetHealthCheck.targetHealthCheckId, + { onDelete: "cascade" } + ), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + lastTriggeredAt: integer("lastTriggeredAt"), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() +}); + +export const alertEmailActions = sqliteTable("alertEmailActions", { + emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + lastSentAt: integer("lastSentAt") +}); + +export const alertEmailRecipients = sqliteTable("alertEmailRecipients", { + recipientId: integer("recipientId").primaryKey({ autoIncrement: true }), + emailActionId: integer("emailActionId") + .notNull() + .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), + userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), + roleId: text("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + email: text("email") +}); + +export const alertWebhookActions = sqliteTable("alertWebhookActions", { + webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }), + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + webhookUrl: text("webhookUrl").notNull(), + secret: text("secret"), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + lastSentAt: integer("lastSentAt") +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel;