mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-27 19:22:50 +00:00
Merge pull request #3004 from Fredkiss3/feat/labels-on-sites-and-resources
feat: site & resource labels
This commit is contained in:
@@ -148,6 +148,12 @@ export enum ActionsEnum {
|
||||
updateAlertRule = "updateAlertRule",
|
||||
deleteAlertRule = "deleteAlertRule",
|
||||
listAlertRules = "listAlertRules",
|
||||
listOrgLabels = "listOrgLabels",
|
||||
createOrgLabel = "createOrgLabel",
|
||||
updateOrgLabel = "updateOrgLabel",
|
||||
deleteOrgLabel = "deleteOrgLabel",
|
||||
attachLabelToItem = "attachLabelToItem",
|
||||
detachLabelFromItem = "detachLabelFromItem",
|
||||
getAlertRule = "getAlertRule",
|
||||
createHealthCheck = "createHealthCheck",
|
||||
updateHealthCheck = "updateHealthCheck",
|
||||
|
||||
@@ -162,6 +162,89 @@ 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()
|
||||
},
|
||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
||||
);
|
||||
|
||||
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()
|
||||
},
|
||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
||||
);
|
||||
|
||||
export const siteResourceLabels = pgTable(
|
||||
"siteResourceLabels",
|
||||
{
|
||||
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
||||
);
|
||||
|
||||
export const clientLabels = pgTable(
|
||||
"clientLabels",
|
||||
{
|
||||
clientLabelId: serial("clientLabelId").primaryKey(),
|
||||
clientId: integer("clientId")
|
||||
.references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||
);
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
targetId: serial("targetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
@@ -196,9 +279,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 +1182,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<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
@@ -1179,3 +1275,4 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type Label = InferSelectModel<typeof labels>;
|
||||
|
||||
@@ -183,6 +183,95 @@ export const resources = sqliteTable("resources", {
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const labels = sqliteTable("labels", {
|
||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color").notNull(),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const siteLabels = sqliteTable(
|
||||
"siteLabels",
|
||||
{
|
||||
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
||||
);
|
||||
|
||||
export const resourceLabels = sqliteTable(
|
||||
"resourceLabels",
|
||||
{
|
||||
resourceLabelId: integer("resourceLabelId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
||||
);
|
||||
|
||||
export const siteResourceLabels = sqliteTable(
|
||||
"siteResourceLabels",
|
||||
{
|
||||
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
||||
);
|
||||
|
||||
export const clientLabels = sqliteTable(
|
||||
"clientLabels",
|
||||
{
|
||||
clientLabelId: integer("clientLabelId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
clientId: integer("clientId")
|
||||
.references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||
);
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||
resourceId: integer("resourceId")
|
||||
@@ -219,9 +308,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}).notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: text("name"),
|
||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
@@ -1196,19 +1287,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const statusHistory = sqliteTable("statusHistory", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
||||
}, (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 = sqliteTable(
|
||||
"statusHistory",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||
timestamp: integer("timestamp").notNull() // unix epoch seconds
|
||||
},
|
||||
(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<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
@@ -1278,3 +1380,4 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type Label = InferSelectModel<typeof labels>;
|
||||
|
||||
@@ -24,10 +24,12 @@ export enum TierFeature {
|
||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||
AlertingRules = "alertingRules",
|
||||
WildcardSubdomain = "wildcardSubdomain"
|
||||
WildcardSubdomain = "wildcardSubdomain",
|
||||
Labels = "labels"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||
|
||||
@@ -24,7 +24,8 @@ import { LogStreamingManager } from "./LogStreamingManager";
|
||||
*/
|
||||
export const logStreamingManager = new LogStreamingManager();
|
||||
|
||||
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
|
||||
if (build !== "saas") {
|
||||
// this is handled separately in the saas build, so we don't want to start it here
|
||||
logStreamingManager.start();
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
if (build != "saas") {
|
||||
if (build !== "saas") {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,59 @@ authenticated.get(
|
||||
alertRule.getAlertRule
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/labels",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
||||
labels.listOrgLabels
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/labels",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
||||
labels.createOrgLabel
|
||||
);
|
||||
|
||||
authenticated.patch(
|
||||
"/org/:orgId/label/:labelId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
||||
labels.updateOrgLabel
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/label/:labelId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
||||
labels.deleteOrgLabel
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/label/:labelId/attach",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
||||
labels.attachLabelToItem
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/label/:labelId/detach",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
||||
labels.detachLabelFromItem
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/health-checks",
|
||||
verifyValidLicense,
|
||||
|
||||
224
server/private/routers/labels/attachLabelToItem.ts
Normal file
224
server/private/routers/labels/attachLabelToItem.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* 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 {
|
||||
clients,
|
||||
clientLabels,
|
||||
db,
|
||||
labels,
|
||||
resourceLabels,
|
||||
resources,
|
||||
siteLabels,
|
||||
siteResourceLabels,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull } 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 attachLabelBodySchema = z.strictObject({
|
||||
siteId: z.number().int().optional(),
|
||||
resourceId: z.number().int().optional(),
|
||||
siteResourceId: z.number().int().optional(),
|
||||
clientId: z.number().int().optional()
|
||||
});
|
||||
|
||||
export async function attachLabelToItem(
|
||||
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 = attachLabelBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, resourceId, siteResourceId, clientId } =
|
||||
parsedBody.data;
|
||||
|
||||
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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 with Id ${labelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (siteId) {
|
||||
const siteCount = await db.$count(
|
||||
sites,
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
);
|
||||
|
||||
if (siteCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with Id ${siteId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||
await db
|
||||
.insert(siteLabels)
|
||||
.values({
|
||||
labelId,
|
||||
siteId
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
resources,
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with Id ${resourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||
await db
|
||||
.insert(resourceLabels)
|
||||
.values({
|
||||
labelId,
|
||||
resourceId
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (siteResourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
siteResources,
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||
await db
|
||||
.insert(siteResourceLabels)
|
||||
.values({
|
||||
labelId,
|
||||
siteResourceId
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
const clientCount = await db.$count(
|
||||
clients,
|
||||
and(
|
||||
eq(clients.clientId, clientId),
|
||||
eq(clients.orgId, orgId),
|
||||
isNull(clients.userId)
|
||||
)
|
||||
);
|
||||
|
||||
if (clientCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Client with Id ${clientId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||
await db
|
||||
.insert(clientLabels)
|
||||
.values({
|
||||
labelId,
|
||||
clientId
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Label attached successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
149
server/private/routers/labels/createOrgLabel.ts
Normal file
149
server/private/routers/labels/createOrgLabel.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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
|
||||
} 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()
|
||||
});
|
||||
|
||||
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,
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
);
|
||||
|
||||
if (siteCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Site with Id ${siteId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
resources,
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
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<CreateOrEditLabelResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
72
server/private/routers/labels/deleteOrgLabel.ts
Normal file
72
server/private/routers/labels/deleteOrgLabel.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 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())
|
||||
});
|
||||
|
||||
export async function deleteOrgLabel(
|
||||
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 [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"));
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(labels)
|
||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Label deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
224
server/private/routers/labels/detachLabelFromItem.ts
Normal file
224
server/private/routers/labels/detachLabelFromItem.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* 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 {
|
||||
clients,
|
||||
clientLabels,
|
||||
db,
|
||||
labels,
|
||||
resourceLabels,
|
||||
resources,
|
||||
siteLabels,
|
||||
siteResourceLabels,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull } 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 detachLabelBodySchema = z.strictObject({
|
||||
siteId: z.number().int().optional(),
|
||||
resourceId: z.number().int().optional(),
|
||||
siteResourceId: z.number().int().optional(),
|
||||
clientId: z.number().int().optional()
|
||||
});
|
||||
|
||||
export async function detachLabelFromItem(
|
||||
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 = detachLabelBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, resourceId, siteResourceId, clientId } =
|
||||
parsedBody.data;
|
||||
|
||||
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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 with Id ${labelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (siteId) {
|
||||
const siteCount = await db.$count(
|
||||
sites,
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
);
|
||||
|
||||
if (siteCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with Id ${siteId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(siteLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(siteLabels.labelId, labelId),
|
||||
eq(siteLabels.siteId, siteId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
resources,
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with Id ${resourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(resourceLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceLabels.labelId, labelId),
|
||||
eq(resourceLabels.resourceId, resourceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (siteResourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
siteResources,
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(siteResourceLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResourceLabels.labelId, labelId),
|
||||
eq(siteResourceLabels.siteResourceId, siteResourceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
const clientCount = await db.$count(
|
||||
clients,
|
||||
and(
|
||||
eq(clients.clientId, clientId),
|
||||
eq(clients.orgId, orgId),
|
||||
isNull(clients.userId)
|
||||
)
|
||||
);
|
||||
|
||||
if (clientCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Client with Id ${clientId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(clientLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(clientLabels.labelId, labelId),
|
||||
eq(clientLabels.clientId, clientId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Label detached successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
19
server/private/routers/labels/index.ts
Normal file
19
server/private/routers/labels/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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";
|
||||
export * from "./attachLabelToItem";
|
||||
export * from "./detachLabelFromItem";
|
||||
export * from "./deleteOrgLabel";
|
||||
155
server/private/routers/labels/listOrgLabels.ts
Normal file
155
server/private/routers/labels/listOrgLabels.ts
Normal file
@@ -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<string>() // 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<string>() // 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<any> {
|
||||
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<ListOrgLabelsResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
101
server/private/routers/labels/updateOrgLabel.ts
Normal file
101
server/private/routers/labels/updateOrgLabel.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 { 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<CreateOrEditLabelResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import {
|
||||
clientLabels,
|
||||
clients,
|
||||
clientSitesAssociationsCache,
|
||||
currentFingerprint,
|
||||
db,
|
||||
labels,
|
||||
olms,
|
||||
orgs,
|
||||
roleClients,
|
||||
sites,
|
||||
userClients,
|
||||
users
|
||||
users,
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -169,6 +174,7 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
||||
siteNiceId: string | null;
|
||||
}>;
|
||||
olmUpdateAvailable?: boolean;
|
||||
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
||||
};
|
||||
|
||||
type OlmWithUpdateAvailable = ClientWithSites;
|
||||
@@ -255,6 +261,11 @@ export async function listClients(
|
||||
(client) => client.clientId
|
||||
);
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
// Get client count with filter
|
||||
const conditions = [
|
||||
and(
|
||||
@@ -288,18 +299,29 @@ export async function listClients(
|
||||
}
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${clients.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${clients.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
like(sql`LOWER(${clients.name})`, q),
|
||||
like(sql`LOWER(${clients.niceId})`, q)
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
queryList.push(
|
||||
inArray(
|
||||
clients.clientId,
|
||||
db
|
||||
.select({ id: clientLabels.clientId })
|
||||
.from(clientLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, clientLabels.labelId)
|
||||
)
|
||||
.where(like(sql`LOWER(${labels.name})`, q))
|
||||
)
|
||||
)
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push(or(...queryList));
|
||||
}
|
||||
|
||||
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||
@@ -326,6 +348,30 @@ export async function listClients(
|
||||
const clientIds = clientsList.map((client) => client.clientId);
|
||||
const siteAssociations = await getSiteAssociations(clientIds);
|
||||
|
||||
let labelsForClients: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
clientId: number;
|
||||
}> = [];
|
||||
|
||||
if (isLabelFeatureEnabled && clientIds.length > 0) {
|
||||
labelsForClients = await db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
clientId: clientLabels.clientId
|
||||
})
|
||||
.from(labels)
|
||||
.innerJoin(
|
||||
clientLabels,
|
||||
eq(clientLabels.labelId, labels.labelId)
|
||||
)
|
||||
.where(inArray(clientLabels.clientId, clientIds))
|
||||
.orderBy(asc(clientLabels.clientLabelId));
|
||||
}
|
||||
|
||||
// Group site associations by client ID
|
||||
const sitesByClient = siteAssociations.reduce(
|
||||
(acc, association) => {
|
||||
@@ -353,7 +399,10 @@ export async function listClients(
|
||||
const clientsWithSites = clientsList.map((client) => {
|
||||
return {
|
||||
...client,
|
||||
sites: sitesByClient[client.clientId] || []
|
||||
sites: sitesByClient[client.clientId] || [],
|
||||
labels: labelsForClients.filter(
|
||||
(l) => l.clientId === client.clientId
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
10
server/routers/labels/types.ts
Normal file
10
server/routers/labels/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Label } from "@server/db";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
export type ListOrgLabelsResponse = PaginatedResponse<{
|
||||
labels: Omit<Label, "orgId">[];
|
||||
}>;
|
||||
|
||||
export type CreateOrEditLabelResponse = {
|
||||
label: Label;
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
db,
|
||||
labels,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourceLabels,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resources,
|
||||
@@ -9,8 +11,11 @@ import {
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
targets,
|
||||
userResources
|
||||
userResources,
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -154,6 +159,7 @@ export type ResourceWithTargets = {
|
||||
siteNiceId: string;
|
||||
online?: boolean; // undefined for local sites
|
||||
}>;
|
||||
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
||||
};
|
||||
|
||||
function queryResourcesBase() {
|
||||
@@ -288,6 +294,11 @@ export async function listResources(
|
||||
);
|
||||
}
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
let accessibleResources: Array<{ resourceId: number }>;
|
||||
if (req.user) {
|
||||
accessibleResources = await db
|
||||
@@ -325,24 +336,6 @@ export async function listResources(
|
||||
)
|
||||
];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${resources.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resources.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resources.fullDomain})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeof enabled !== "undefined") {
|
||||
conditions.push(eq(resources.enabled, enabled));
|
||||
}
|
||||
@@ -386,6 +379,32 @@ export async function listResources(
|
||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||
}
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
like(sql`LOWER(${resources.name})`, q),
|
||||
like(sql`LOWER(${resources.niceId})`, q),
|
||||
like(sql`LOWER(${resources.fullDomain})`, q)
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
queryList.push(
|
||||
inArray(
|
||||
resources.resourceId,
|
||||
db
|
||||
.select({ id: resourceLabels.resourceId })
|
||||
.from(resourceLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, resourceLabels.labelId)
|
||||
)
|
||||
.where(like(sql`LOWER(${labels.name})`, q))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push(or(...queryList));
|
||||
}
|
||||
|
||||
const baseQuery = queryResourcesBase().where(and(...conditions));
|
||||
|
||||
@@ -407,6 +426,36 @@ export async function listResources(
|
||||
]);
|
||||
|
||||
const resourceIdList = rows.map((row) => row.resourceId);
|
||||
|
||||
let labelsForResources: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
resourceId: number;
|
||||
}> = [];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
labelsForResources =
|
||||
resourceIdList.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
resourceId: resourceLabels.resourceId
|
||||
})
|
||||
.from(labels)
|
||||
.innerJoin(
|
||||
resourceLabels,
|
||||
eq(resourceLabels.labelId, labels.labelId)
|
||||
)
|
||||
.where(
|
||||
inArray(resourceLabels.resourceId, resourceIdList)
|
||||
)
|
||||
.orderBy(asc(resourceLabels.resourceLabelId));
|
||||
}
|
||||
|
||||
const allResourceTargets =
|
||||
resourceIdList.length === 0
|
||||
? []
|
||||
@@ -458,7 +507,10 @@ export async function listResources(
|
||||
headerAuthId: row.headerAuthId,
|
||||
health: row.health ?? null,
|
||||
targets: [],
|
||||
sites: []
|
||||
sites: [],
|
||||
labels: labelsForResources.filter(
|
||||
(l) => l.resourceId === row.resourceId
|
||||
)
|
||||
};
|
||||
map.set(row.resourceId, entry);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
siteResources,
|
||||
targets,
|
||||
sites,
|
||||
userSites
|
||||
userSites,
|
||||
labels,
|
||||
siteLabels,
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import response from "@server/lib/response";
|
||||
@@ -23,6 +26,8 @@ import createHttpError from "http-errors";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
||||
// a transient network failure / timeout does not flip every site back to
|
||||
@@ -187,7 +192,7 @@ const listSitesSchema = z.object({
|
||||
|
||||
function querySitesBase() {
|
||||
return db
|
||||
.select({
|
||||
.selectDistinct({
|
||||
siteId: sites.siteId,
|
||||
niceId: sites.niceId,
|
||||
name: sites.name,
|
||||
@@ -233,6 +238,7 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
|
||||
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
||||
online?: SiteRowBase["online"]; // undefined for local sites
|
||||
newtUpdateAvailable?: boolean;
|
||||
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
||||
};
|
||||
|
||||
export type ListSitesResponse = PaginatedResponse<{
|
||||
@@ -308,6 +314,11 @@ export async function listSites(
|
||||
.where(eq(sites.orgId, orgId));
|
||||
}
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
const { pageSize, page, query, sort_by, order, online, status } =
|
||||
parsedQuery.data;
|
||||
|
||||
@@ -319,33 +330,43 @@ export async function listSites(
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
];
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${sites.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${sites.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(sites.online, online));
|
||||
}
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(sites.status, status));
|
||||
}
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
like(sql`LOWER(${sites.name})`, q),
|
||||
like(sql`LOWER(${sites.niceId})`, q)
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
queryList.push(
|
||||
inArray(
|
||||
sites.siteId,
|
||||
db
|
||||
.select({ id: siteLabels.siteId })
|
||||
.from(siteLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, siteLabels.labelId)
|
||||
)
|
||||
.where(like(sql`LOWER(${labels.name})`, q))
|
||||
)
|
||||
);
|
||||
}
|
||||
conditions.push(or(...queryList));
|
||||
}
|
||||
|
||||
const baseQuery = querySitesBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(
|
||||
querySitesBase()
|
||||
.where(and(...conditions))
|
||||
.as("filtered_sites")
|
||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
||||
);
|
||||
|
||||
const siteListQuery = baseQuery
|
||||
@@ -367,11 +388,46 @@ export async function listSites(
|
||||
// Get latest version asynchronously without blocking the response
|
||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||
|
||||
const siteIds = rows.map((site) => site.siteId);
|
||||
|
||||
let labelsForSites: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
siteId: number;
|
||||
}> = [];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
labelsForSites =
|
||||
siteIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
siteId: siteLabels.siteId
|
||||
})
|
||||
.from(labels)
|
||||
.innerJoin(
|
||||
siteLabels,
|
||||
eq(siteLabels.labelId, labels.labelId)
|
||||
)
|
||||
.where(inArray(siteLabels.siteId, siteIds))
|
||||
.orderBy(asc(siteLabels.siteLabelId));
|
||||
}
|
||||
|
||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
siteWithUpdate.newtUpdateAvailable = false;
|
||||
return siteWithUpdate;
|
||||
|
||||
// associate labels
|
||||
const labelsForSite = labelsForSites.filter(
|
||||
(label) => label.siteId === site.siteId
|
||||
);
|
||||
|
||||
return { ...siteWithUpdate, labels: labelsForSite };
|
||||
});
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
DB_TYPE,
|
||||
Label,
|
||||
SiteResource,
|
||||
siteNetworks,
|
||||
siteResourceLabels,
|
||||
siteResources,
|
||||
sites,
|
||||
labels
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -9,6 +19,8 @@ import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -69,16 +81,11 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
siteId: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.openapi({
|
||||
type: "integer",
|
||||
description:
|
||||
"When set, only site resources associated with this site (via network) are returned"
|
||||
})
|
||||
siteId: z.coerce.number<string>().int().positive().optional().openapi({
|
||||
type: "integer",
|
||||
description:
|
||||
"When set, only site resources associated with this site (via network) are returned"
|
||||
})
|
||||
});
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
@@ -88,6 +95,7 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
siteNames: string[];
|
||||
siteNiceIds: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
||||
})[];
|
||||
}>;
|
||||
|
||||
@@ -234,6 +242,11 @@ export async function listAllSiteResourcesByOrg(
|
||||
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
||||
parsedQuery.data;
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||
|
||||
if (siteId != null) {
|
||||
@@ -258,41 +271,41 @@ export async function listAllSiteResourcesByOrg(
|
||||
inArray(siteResources.siteResourceId, resourcesForSite)
|
||||
);
|
||||
}
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${siteResources.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.destination})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.alias})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.aliasAddress})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${sites.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (mode) {
|
||||
conditions.push(eq(siteResources.mode, mode));
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
like(sql`LOWER(${siteResources.name})`, q),
|
||||
like(sql`LOWER(${siteResources.niceId})`, q),
|
||||
like(sql`LOWER(${siteResources.destination})`, q),
|
||||
like(sql`LOWER(${siteResources.alias})`, q),
|
||||
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
||||
like(sql`LOWER(${sites.name})`, q)
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
queryList.push(
|
||||
inArray(
|
||||
siteResources.siteResourceId,
|
||||
db
|
||||
.select({ id: siteResourceLabels.siteResourceId })
|
||||
.from(siteResourceLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, siteResourceLabels.labelId)
|
||||
)
|
||||
.where(like(sql`LOWER(${labels.name})`, q))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push(or(...queryList));
|
||||
}
|
||||
|
||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
@@ -315,11 +328,51 @@ export async function listAllSiteResourcesByOrg(
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
|
||||
const siteResourcesList = siteResourcesRaw.map(
|
||||
transformSiteResourceRow
|
||||
);
|
||||
|
||||
const siteResourceIdList = siteResourcesList.map(
|
||||
(r) => r.siteResourceId
|
||||
);
|
||||
|
||||
let labelsForSiteResources: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
siteResourceId: number;
|
||||
}> = [];
|
||||
|
||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
||||
labelsForSiteResources = await db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
siteResourceId: siteResourceLabels.siteResourceId
|
||||
})
|
||||
.from(labels)
|
||||
.innerJoin(
|
||||
siteResourceLabels,
|
||||
eq(siteResourceLabels.labelId, labels.labelId)
|
||||
)
|
||||
.where(
|
||||
inArray(
|
||||
siteResourceLabels.siteResourceId,
|
||||
siteResourceIdList
|
||||
)
|
||||
)
|
||||
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
|
||||
}
|
||||
|
||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||
data: {
|
||||
siteResources: siteResourcesList,
|
||||
siteResources: siteResourcesList.map((r) => ({
|
||||
...r,
|
||||
labels: labelsForSiteResources.filter(
|
||||
(l) => l.siteResourceId === r.siteResourceId
|
||||
)
|
||||
})),
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
@@ -340,4 +393,4 @@ export async function listAllSiteResourcesByOrg(
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user