diff --git a/SW.Bitween.Api/Resources/ApiGateways/Create.cs b/SW.Bitween.Api/Resources/ApiGateways/Create.cs index 2e3e5e9b..b5129ffc 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Create.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Create.cs @@ -13,6 +13,7 @@ public async Task Handle(ApiGatewayCreate model) await requestContext.EnsurePermission(dbContext, Model.Permissions.ApiGateways.Create); GatewayUrlName.Validate(model.UrlName); + await GatewayUrlName.EnsureIsFree(dbContext, model.UrlName); var entity = new ApiGateway { diff --git a/SW.Bitween.Api/Resources/ApiGateways/GatewayUrlName.cs b/SW.Bitween.Api/Resources/ApiGateways/GatewayUrlName.cs index 3742d3e7..d9620f83 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/GatewayUrlName.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/GatewayUrlName.cs @@ -1,4 +1,8 @@ using System.Text.RegularExpressions; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SW.Bitween.Domain.Gateway; using SW.PrimitiveTypes; namespace SW.Bitween.Resources.ApiGateways; @@ -24,4 +28,26 @@ public static void Validate(string urlName) $"'{urlName}' cannot be used in a URL. Use lowercase letters, digits, hyphens " + "and underscores only — no spaces, and not starting or ending with a separator."); } + + /// + /// Refuses a url name another gateway already answers on. Pass the gateway's own id when + /// updating, so saving a gateway without touching its url name isn't a collision with itself. + /// + /// + /// The column is uniquely indexed, so this was already refused — as a constraint violation + /// that reached the screen as "Request failed (500)", with nothing to say the name was taken + /// or which gateway has it. + /// + public static async Task EnsureIsFree(BitweenDbContext dbContext, string urlName, int? existingId = null) + { + var taken = await dbContext.Set().AsNoTracking() + .Where(gateway => gateway.UrlName == urlName && gateway.Id != existingId) + .Select(gateway => gateway.Name) + .FirstOrDefaultAsync(); + + if (taken != null) + throw new SWValidationException("GATEWAY_URL_NAME_TAKEN", + $"'{urlName}' is already the address of the gateway '{taken}'. " + + "Partners reach a gateway by this name, so two can't share one."); + } } diff --git a/SW.Bitween.Api/Resources/ApiGateways/Update.cs b/SW.Bitween.Api/Resources/ApiGateways/Update.cs index c9fce196..791f125a 100644 --- a/SW.Bitween.Api/Resources/ApiGateways/Update.cs +++ b/SW.Bitween.Api/Resources/ApiGateways/Update.cs @@ -23,6 +23,7 @@ public async Task Handle(int key, ApiGatewayUpdate model) throw new SWNotFoundException($"ApiGateway with Id {key} not found"); GatewayUrlName.Validate(model.UrlName); + await GatewayUrlName.EnsureIsFree(dbContext, model.UrlName, key); entity.Name = model.Name; entity.UrlName = model.UrlName; diff --git a/SW.Bitween.Api/Resources/DataSources/Delete.cs b/SW.Bitween.Api/Resources/DataSources/Delete.cs index f001cbb6..03cb3022 100644 --- a/SW.Bitween.Api/Resources/DataSources/Delete.cs +++ b/SW.Bitween.Api/Resources/DataSources/Delete.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain.DataSources; +using SW.Bitween.Domain; using SW.Bitween.Domain.Gateway; using SW.EfCoreExtensions; using SW.PrimitiveTypes; @@ -28,6 +29,21 @@ public async Task Handle(int key) string.Join(", ", gateways) + ". Point them at the internal bus, or delete them, first."); + // Subscription.DataSourceId restricts too, and was the one left to the database — the + // commoner case of the two, since a data source is normally read by an integration long + // before any gateway feeds off it. + var subscriptions = await dbContext.Set() + .Where(subscription => subscription.DataSourceId == key) + .Select(subscription => subscription.Name) + .Take(5) + .ToListAsync(); + + if (subscriptions.Count > 0) + throw new SWException( + "Cannot delete a data source that integrations still read: " + + string.Join(", ", subscriptions) + + ". Point them at another data source, or delete them, first."); + // Dedupe keys cascade with the data source, which is what makes deleting and recreating a // data source a genuine reset rather than one that silently suppresses the first messages. await dbContext.DeleteByKeyAsync(key); diff --git a/SW.Bitween.Api/Resources/Partners/Delete.cs b/SW.Bitween.Api/Resources/Partners/Delete.cs index 5bc8517d..21e48b6f 100644 --- a/SW.Bitween.Api/Resources/Partners/Delete.cs +++ b/SW.Bitween.Api/Resources/Partners/Delete.cs @@ -1,10 +1,11 @@ -using SW.EfCoreExtensions; +using SW.EfCoreExtensions; using SW.Bitween.Domain; +using SW.Bitween.Domain.Gateway; using SW.PrimitiveTypes; -using System; using System.Collections.Generic; -using System.Text; +using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; namespace SW.Bitween.Resources.Partners { @@ -17,8 +18,76 @@ public async Task Handle(int key) if (key == Partner.SystemId) throw new SWException("System partner can not be deleted."); + await EnsureNothingPointsAtIt(key); + await dbContext.DeleteByKeyAsync(key); return null; } + + /// + /// Says what still points at the partner, before the database says it less politely. + /// + /// + /// All three references are RESTRICT, so the delete was already refused — but as a + /// foreign key violation surfacing as a bare 500, which names a constraint instead of the + /// integration holding it and reads like a broken screen rather than a decision. + /// Exchanges are deliberately not checked: they carry a partner id with no foreign key + /// behind it, and history is not a reason to keep configuration alive. + /// + private async Task EnsureNothingPointsAtIt(int key) + { + var heldBy = new List(); + + var subscriptions = await dbContext.Set() + .Where(s => s.PartnerId == key) + .Select(s => s.Name) + .Take(MaxNamed + 1) + .ToArrayAsync(); + if (subscriptions.Length > 0) + heldBy.Add($"the integration {Describe(subscriptions)}"); + + var apiGateways = await dbContext.Set() + .Where(p => p.PartnerId == key) + .Select(p => p.ApiGateway.Name) + .Distinct() + .Take(MaxNamed + 1) + .ToArrayAsync(); + if (apiGateways.Length > 0) + heldBy.Add($"an attachment on {Describe(apiGateways)}"); + + var busGateways = await dbContext.Set() + .Where(r => r.PartnerId == key) + .Select(r => r.BusGateway.Name) + .Distinct() + .Take(MaxNamed + 1) + .ToArrayAsync(); + if (busGateways.Length > 0) + heldBy.Add($"a route on {Describe(busGateways)}"); + + if (heldBy.Count == 0) + return; + + throw new SWValidationException("PARTNER_IN_USE", + $"This partner is still used by {Join(heldBy.ToArray())}. " + + "Remove that first, or point it at another partner."); + } + + /// + /// How many holders the message names before it stops listing them. A partner carrying + /// forty integrations would otherwise read out all forty into a dialog — longer to + /// understand than the short version, and forty rows fetched to build it. + /// + private const int MaxNamed = 5; + + /// Takes one more than it will name, which is how it knows there are others. + private static string Describe(string[] names) => + names.Length > MaxNamed + ? $"{string.Join(", ", names[..MaxNamed])} and others" + : Join(names); + + private static string Join(string[] names) => + names.Length == 1 + ? names[0] + : $"{string.Join(", ", names[..^1])} and {names[^1]}"; } -} \ No newline at end of file +} diff --git a/SW.Bitween.Api/Resources/SubscriptionCategories/Create.cs b/SW.Bitween.Api/Resources/SubscriptionCategories/Create.cs index 473983c9..dc45345c 100644 --- a/SW.Bitween.Api/Resources/SubscriptionCategories/Create.cs +++ b/SW.Bitween.Api/Resources/SubscriptionCategories/Create.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; + using SW.Bitween.Domain; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -13,6 +14,8 @@ public async Task Handle(CreateSubscriptionCategoryModel request) { await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.Create); + await EnsureCodeIsFree(dbContext, request.Code); + var category = new SubscriptionCategory(request.Code, request.Description); dbContext.Add(category); await dbContext.SaveChangesAsync(); @@ -21,4 +24,18 @@ public async Task Handle(CreateSubscriptionCategoryModel request) category.Id }; } + + /// + /// The code is uniquely indexed, so a repeat was already refused — as a constraint violation + /// surfacing as a bare 500, which says nothing about the code being taken. + /// + internal static async Task EnsureCodeIsFree(BitweenDbContext dbContext, string code, int? existingId = null) + { + var taken = await dbContext.Set().AsNoTracking() + .AnyAsync(c => c.Code == code && c.Id != existingId); + + if (taken) + throw new SWValidationException("CATEGORY_CODE_TAKEN", + $"A category with the code '{code}' already exists."); + } } \ No newline at end of file diff --git a/SW.Bitween.Api/Resources/SubscriptionCategories/Update.cs b/SW.Bitween.Api/Resources/SubscriptionCategories/Update.cs index b7058a5f..aa05b5c1 100644 --- a/SW.Bitween.Api/Resources/SubscriptionCategories/Update.cs +++ b/SW.Bitween.Api/Resources/SubscriptionCategories/Update.cs @@ -15,6 +15,9 @@ public async Task Handle(int key, CreateSubscriptionCategoryModel reques var category = await dbContext.Set().FindAsync(key); if (category is null) throw new SWValidationException("CATEGORY_NOT_FOUND", $"Category with id {key} was not found"); + + await Create.EnsureCodeIsFree(dbContext, request.Code, key); + category.Update(request.Code, request.Description); await dbContext.SaveChangesAsync(); return null; diff --git a/SW.Bitween.Api/Resources/WorkGroups/Delete.cs b/SW.Bitween.Api/Resources/WorkGroups/Delete.cs index 60f4c7e9..903e9b42 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Delete.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Delete.cs @@ -1,6 +1,8 @@ using System.Threading.Tasks; +using System.Linq; using Microsoft.EntityFrameworkCore; using SW.Bitween.Domain; +using SW.Bitween.Domain.DataSources; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -21,6 +23,19 @@ public async Task Handle(int key, DeleteWorkGroupModel _) if (await dbContext.Set().AnyAsync(i => i.WorkGroupId.Value == category.Id)) throw new SWValidationException("CANT_BE_DELETED", "Workgroup with Subscriptions cant be deleted"); + // Data source statements run on a work group too, and that foreign key restricts as well — + // left unchecked it refused the delete as a bare 500 naming the constraint. + var statements = await dbContext.Set() + .Where(s => s.WorkGroupId == key) + .Select(s => s.Name) + .Take(5) + .ToListAsync(); + if (statements.Count > 0) + throw new SWValidationException("CANT_BE_DELETED", + "Cannot delete a work group that data source statements run on: " + + string.Join(", ", statements) + + ". Point them at another work group first."); + //Todo chek rabbitMq dbContext.Remove(category); await dbContext.SaveChangesAsync(); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx b/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx index 54be33d6..38303088 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/MatchExpressionEditor.tsx @@ -1,5 +1,7 @@ import { Plus, Trash2 } from "lucide-react"; +import { Link } from "react-router"; import type { MatchCondition, MatchGroup, MatchNode } from "../../api"; +import { useSessionCan } from "../../auth/guards"; import { matchSummary } from "../../lib/match"; import { Button } from "../ui/basics"; import { Select } from "../ui/forms"; @@ -168,13 +170,23 @@ export function MatchExpressionEditor({ onChange, properties, disabled, + informationTypeId, }: { value: MatchGroup | null; onChange: (value: MatchGroup | null) => void; /** Promoted properties of the information type being filtered (friendly name + JSON path). */ properties: { key: string; path: string }[]; disabled: boolean; + /** + * The type those properties come from. Only used to point at it when it has none — + * without it the empty state names the fix without offering it. + */ + informationTypeId?: number | null; }) { + // Not `disabled`: that says whether this filter can be edited, which is a different + // grant from the one that would let you go and add the properties it needs. + const canEditTypes = useSessionCan("documents.edit"); + if (value === null) { return (
@@ -194,7 +206,15 @@ export function MatchExpressionEditor({ )} {properties.length === 0 && (

- Filters match on promoted properties — this information type has none yet. + Filters match on promoted properties — this information type has none yet.{" "} + {informationTypeId != null && canEditTypes && ( + + Add some + + )}

)}
diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx index 4de196d0..546f5724 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { X } from "lucide-react"; @@ -89,6 +89,24 @@ export function NewGatewaySubscriptionPage() { const [draft, update] = useDraft(EMPTY); + /** + * Fills the name in from the gateway, so the field is one to accept rather than one + * to invent. Deliberately not from the partner in `?partnerId=`, even though it is + * right there: an attachment is (gateway, partner, subscription), and one subscription + * is normally shared by every partner on the gateway. Seeding it with whichever partner + * happened to be picked first would name a shared pipeline after one of its callers. + * + * Seeded once: `touched` latches as soon as the field is edited, so nothing overwrites + * a typed name. + */ + const touched = useRef(false); + const gatewayName = gateway.data?.name; + useEffect(() => { + if (touched.current || !gatewayName) return; + update({ name: gatewayName }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gatewayName]); + /** Back to the attach page, carrying the partner along if one was already picked. */ const backToAttach = (extra: Record) => { const query = new URLSearchParams(extra); @@ -265,7 +283,10 @@ export function NewGatewaySubscriptionPage() { value={draft.name} autoFocus placeholder="e.g. Coral orders to SAP" - onChange={(e) => update({ name: e.target.value })} + onChange={(e) => { + touched.current = true; + update({ name: e.target.value }); + }} /> diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx index 162fafec..2b8b6b74 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx @@ -151,6 +151,7 @@ export function RouteBody({ onChange={(matchExpression) => onChange({ matchExpression })} properties={promotedProperties} disabled={disabled} + informationTypeId={informationTypeId} /> diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx index 76361118..0a7d73ef 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx @@ -241,6 +241,7 @@ export function SubscriptionPage() { onChange={(matchExpression) => set("matchExpression", matchExpression)} properties={infoType.data?.promotedProperties ?? []} disabled={!canEdit} + informationTypeId={infoType.data?.id} /> ) : (