Handle deleting client and orphaning resources

This commit is contained in:
Owen
2026-04-28 22:19:03 -07:00
parent 2203ebf723
commit a44100c2bd
5 changed files with 77 additions and 28 deletions

View File

@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, Site, siteNetworks, siteResources } from "@server/db"; import { db, Site, siteNetworks, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db"; import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -77,17 +77,25 @@ export async function deleteSite(
.where(eq(siteNetworks.siteId, siteId)); .where(eq(siteNetworks.siteId, siteId));
// loop through them // loop through them
for (const network of await networks) { const updatedSiteResources = await trx
const [siteResource] = await trx .select()
.select() .from(siteResources)
.from(siteResources) .where(
.where(eq(siteResources.networkId, network.networkId)); inArray(
if (siteResource) { siteResources.networkId,
await rebuildClientAssociationsFromSiteResource( networks.map((n) => n.networkId)
siteResource, )
trx );
for (const siteResource of updatedSiteResources) {
rebuildClientAssociationsFromSiteResource(
siteResource,
trx
).catch((error) => {
logger.error(
"Failed to rebuild client associations from site resource:",
error
); );
} });
} }
// get the newt on the site by querying the newt table for siteId // get the newt on the site by querying the newt table for siteId

View File

@@ -47,6 +47,7 @@ const createSiteResourceSchema = z
ssl: z.boolean().optional(), // only used for http mode ssl: z.boolean().optional(), // only used for http mode
scheme: z.enum(["http", "https"]).optional(), scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()), siteIds: z.array(z.int()),
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
destination: z.string().min(1), destination: z.string().min(1),
@@ -187,7 +188,8 @@ export async function createSiteResource(
const { const {
name, name,
niceId, niceId,
siteIds, siteIds: siteIdsInput,
siteId,
mode, mode,
scheme, scheme,
// proxyPort, // proxyPort,
@@ -208,6 +210,12 @@ export async function createSiteResource(
subdomain subdomain
} = parsedBody.data; } = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
if (mode == "http") { if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed( const hasHttpFeature = await isLicensedOrSubscribed(
orgId, orgId,

View File

@@ -98,9 +98,11 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
*/ */
function aggCol<T>(column: any) { function aggCol<T>(column: any) {
if (DB_TYPE === "sqlite") { if (DB_TYPE === "sqlite") {
// json_group_array will include NULLs for left-joined missing rows;
// we filter them out in transformSiteResourceRow keeping arrays aligned.
return sql<T>`json_group_array(${column})`; return sql<T>`json_group_array(${column})`;
} }
return sql<T>`array_agg(${column})`; return sql<T>`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`;
} }
/** /**
@@ -112,16 +114,36 @@ function transformSiteResourceRow(row: any) {
if (DB_TYPE !== "sqlite") { if (DB_TYPE !== "sqlite") {
return row; return row;
} }
const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[];
const siteNamesRaw = JSON.parse(row.siteNames) as (string | null)[];
const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (string | null)[];
const siteAddressesRaw = JSON.parse(row.siteAddresses) as (string | null)[];
const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (0 | 1 | null)[];
// When a site resource has no associated sites (left join produced no
// matches), the aggregated arrays will contain a single NULL entry. Strip
// those out, keeping the parallel arrays aligned by siteId presence.
const siteIds: number[] = [];
const siteNames: string[] = [];
const siteNiceIds: string[] = [];
const siteAddresses: (string | null)[] = [];
const siteOnlines: boolean[] = [];
for (let i = 0; i < siteIdsRaw.length; i++) {
if (siteIdsRaw[i] == null) continue;
siteIds.push(siteIdsRaw[i] as number);
siteNames.push((siteNamesRaw[i] ?? "") as string);
siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string);
siteAddresses.push(siteAddressesRaw[i] ?? null);
siteOnlines.push(siteOnlinesRaw[i] === 1);
}
return { return {
...row, ...row,
siteNames: JSON.parse(row.siteNames) as string[], siteNames,
siteNiceIds: JSON.parse(row.siteNiceIds) as string[], siteNiceIds,
siteIds: JSON.parse(row.siteIds) as number[], siteIds,
siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[], siteAddresses,
// SQLite stores booleans as 0/1 integers siteOnlines
siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map(
(v) => v === 1
) as boolean[]
}; };
} }
@@ -158,11 +180,11 @@ function querySiteResourcesBase() {
siteOnlines: aggCol<boolean[]>(sites.online) siteOnlines: aggCol<boolean[]>(sites.online)
}) })
.from(siteResources) .from(siteResources)
.innerJoin( .leftJoin(
siteNetworks, siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId) eq(siteResources.networkId, siteNetworks.networkId)
) )
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.groupBy(siteResources.siteResourceId); .groupBy(siteResources.siteResourceId);
} }
@@ -215,6 +237,8 @@ export async function listAllSiteResourcesByOrg(
const conditions = [and(eq(siteResources.orgId, orgId))]; const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) { if (siteId != null) {
// Keep inner joins here: filtering by a specific site implies the
// resource must have at least one matching site.
const resourcesForSite = db const resourcesForSite = db
.select({ id: siteResources.siteResourceId }) .select({ id: siteResources.siteResourceId })
.from(siteResources) .from(siteResources)

View File

@@ -44,6 +44,7 @@ const updateSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
siteIds: z.array(z.int()), siteIds: z.array(z.int()),
siteId: z.int().positive().optional(),
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z niceId: z
.string() .string()
@@ -196,7 +197,8 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const { const {
name, name,
siteIds, // because it can change siteIds: siteIdsInput, // because it can change
siteId,
niceId, niceId,
mode, mode,
scheme, scheme,
@@ -217,6 +219,12 @@ export async function updateSiteResource(
subdomain subdomain
} = parsedBody.data; } = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
// Check if site resource exists // Check if site resource exists
const [existingSiteResource] = await db const [existingSiteResource] = await db
.select() .select()

View File

@@ -37,11 +37,8 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table"; import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
@@ -206,7 +203,11 @@ export default function ClientResourcesTable({
const { siteNames, siteNiceIds, orgId } = resourceRow; const { siteNames, siteNiceIds, orgId } = resourceRow;
if (!siteNames || siteNames.length === 0) { if (!siteNames || siteNames.length === 0) {
return <span>-</span>; return (
<span className="text-muted-foreground">
{t("noSites", { defaultValue: "No sites" })}
</span>
);
} }
if (siteNames.length === 1) { if (siteNames.length === 1) {