diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 9ba1b5bce..f6bfa2549 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -148,6 +148,9 @@ export enum ActionsEnum { updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", listAlertRules = "listAlertRules", + listOrgLabels = "listOrgLabels", + createOrgLabel = "createOrgLabel", + updateOrgLabel = "updateOrgLabel", getAlertRule = "getAlertRule", createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7fbcef621..0a4066db3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -162,6 +162,45 @@ export const resources = pgTable("resources", { wildcard: boolean("wildcard").notNull().default(false) }); +export const labels = pgTable("labels", { + labelId: serial("labelId").primaryKey(), + name: varchar("name").notNull(), + color: varchar("color").notNull(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const siteLabels = pgTable("siteLabels", { + siteLabelId: serial("siteLabelId").primaryKey(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const resourceLabels = pgTable("resourceLabels", { + resourceLabelId: serial("resourceLabelId").primaryKey(), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() +}); + export const targets = pgTable("targets", { targetId: serial("targetId").primaryKey(), resourceId: integer("resourceId") @@ -196,9 +235,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), - siteId: integer("siteId").references(() => sites.siteId, { - onDelete: "cascade" - }).notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), name: varchar("name"), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), @@ -1097,19 +1138,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", { complete: boolean("complete").notNull().default(false) }); -export const statusHistory = pgTable("statusHistory", { - id: serial("id").primaryKey(), - entityType: varchar("entityType").notNull(), - entityId: integer("entityId").notNull(), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - status: varchar("status").notNull(), - timestamp: integer("timestamp").notNull(), -}, (table) => [ - index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), - index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), -]); +export const statusHistory = pgTable( + "statusHistory", + { + id: serial("id").primaryKey(), + entityType: varchar("entityType").notNull(), + entityId: integer("entityId").notNull(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: varchar("status").notNull(), + timestamp: integer("timestamp").notNull() + }, + (table) => [ + index("idx_statusHistory_entity").on( + table.entityType, + table.entityId, + table.timestamp + ), + index("idx_statusHistory_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); export type Org = InferSelectModel; export type User = InferSelectModel; @@ -1179,3 +1231,4 @@ export type RoundTripMessageTracker = InferSelectModel< >; export type Network = InferSelectModel; export type StatusHistory = InferSelectModel; +export type Label = InferSelectModel; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index a2667daa1..1b8c1ef4c 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; +import * as labels from "#private/routers/labels"; import { verifyOrgAccess, @@ -732,6 +733,30 @@ authenticated.get( alertRule.getAlertRule ); +authenticated.get( + "/org/:orgId/labels", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listOrgLabels), + labels.listOrgLabels +); + +authenticated.post( + "/org/:orgId/labels", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createOrgLabel), + labels.createOrgLabel +); + +authenticated.patch( + "/org/:orgId/label/:labelId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateOrgLabel), + labels.updateOrgLabel +); + authenticated.get( "/org/:orgId/health-checks", verifyValidLicense, diff --git a/server/private/routers/labels/createOrgLabel.ts b/server/private/routers/labels/createOrgLabel.ts new file mode 100644 index 000000000..1439018d2 --- /dev/null +++ b/server/private/routers/labels/createOrgLabel.ts @@ -0,0 +1,144 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ +import { + db, + labels, + resourceLabels, + resources, + siteLabels, + sites, + type Label +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty(), + color: z + .string() + .regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i) + .nonempty(), + siteId: z.number().int().optional(), + resourceId: z.number().int().optional() +}); + +export async function createOrgLabel( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, color, siteId, resourceId } = parsedBody.data; + + if (siteId) { + const siteCount = await db.$count(sites, eq(sites.siteId, siteId)); + + if (siteCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Site with Id ${siteId} doesn't exist.` + ) + ); + } + } + + if (resourceId) { + const resourceCount = await db.$count( + sites, + eq(resources.resourceId, resourceId) + ); + + if (resourceCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Resource with Id ${resourceId} doesn't exist.` + ) + ); + } + } + + const label = await db.transaction(async (tx) => { + const [label] = await tx + .insert(labels) + .values({ + name, + color, + orgId + }) + .returning(); + + if (siteId) { + await tx.insert(siteLabels).values({ + siteId, + labelId: label.labelId + }); + } + + if (resourceId) { + await tx.insert(resourceLabels).values({ + resourceId, + labelId: label.labelId + }); + } + return label; + }); + + return response(res, { + data: { label }, + success: true, + error: false, + message: "Org Label created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/labels/index.ts b/server/private/routers/labels/index.ts new file mode 100644 index 000000000..4c7e5f432 --- /dev/null +++ b/server/private/routers/labels/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./listOrgLabels"; +export * from "./createOrgLabel"; +export * from "./updateOrgLabel"; diff --git a/server/private/routers/labels/listOrgLabels.ts b/server/private/routers/labels/listOrgLabels.ts new file mode 100644 index 000000000..dc2b50017 --- /dev/null +++ b/server/private/routers/labels/listOrgLabels.ts @@ -0,0 +1,155 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, labels } from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; +import HttpCode from "@server/types/HttpCode"; +import { and, asc, eq, like, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listLabelsSchema = z.object({ + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional() +}); + +function queryLabelsBase() { + return db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(labels); +} + +export async function listOrgLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listLabelsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const { pageSize, page, query } = parsedQuery.data; + + const conditions = [and(eq(labels.orgId, orgId))]; + + if (query) { + conditions.push( + like( + sql`LOWER(${labels.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + + const baseQuery = queryLabelsBase().where(and(...conditions)); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count( + queryLabelsBase() + .where(and(...conditions)) + .as("filtered_labels") + ); + + const labelListQuery = baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(labels.name)); + + const [totalCount, rows] = await Promise.all([ + countQuery, + labelListQuery + ]); + + return response(res, { + data: { + labels: rows, + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "Labels retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/labels/updateOrgLabel.ts b/server/private/routers/labels/updateOrgLabel.ts new file mode 100644 index 000000000..d6478678a --- /dev/null +++ b/server/private/routers/labels/updateOrgLabel.ts @@ -0,0 +1,109 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + db, + labels, + resourceLabels, + resources, + siteLabels, + sites, + type Label +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + labelId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const updateLabelBodySchema = z.strictObject({ + name: z.string().min(1).max(255).optional(), + color: z + .string() + .regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i) + .nonempty() +}); + +export async function updateOrgLabel( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, labelId } = parsedParams.data; + + const parsedBody = updateLabelBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(labels) + .where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId))); + + if (!existing) { + return next(createHttpError(HttpCode.NOT_FOUND, "Label not found")); + } + + const { name, color } = parsedBody.data; + + const [label] = await db + .update(labels) + .set({ + name, + color + }) + .where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId))) + .returning(); + + return response(res, { + data: { + label + }, + success: true, + error: false, + message: "Label updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/labels/types.ts b/server/routers/labels/types.ts new file mode 100644 index 000000000..ad182ad11 --- /dev/null +++ b/server/routers/labels/types.ts @@ -0,0 +1,10 @@ +import type { Label } from "@server/db"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +export type ListOrgLabelsResponse = PaginatedResponse<{ + labels: Omit[]; +}>; + +export type CreateOrEditLabelResponse = { + label: Label; +};