From 6d8b20b5a186d79a7406f8669d8de43786292d67 Mon Sep 17 00:00:00 2001 From: Ajay Bilgaiyan Date: Wed, 11 Nov 2026 23:03:03 +0530 Subject: [PATCH 01/84] FC2-COM-001: add SQL-backed site line and machine inventory store --- .../MasterData/MachineAdministrationStore.cs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs new file mode 100644 index 00000000..5d7e42ef --- /dev/null +++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs @@ -0,0 +1,111 @@ +using System.Data; +using Microsoft.Data.SqlClient; + +namespace FactoryConnect.Sql.MasterData; + +/// +/// SQL-backed site, line and machine inventory. Registration must be called only +/// after the HTTP boundary has independently authorized an administrator. +/// No runtime configuration, acquisition or activation is performed here. +/// +public sealed class MachineAdministrationStore(string connectionString) +{ + public sealed record Site(Guid SiteId, string DisplayName, bool IsRetired); + public sealed record ProductionLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired); + public sealed record Machine(Guid MachineId, Guid SiteId, string DisplayName, bool IsRetired); + + public async Task> ListSitesAsync(CancellationToken ct = default) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(ct); + await using var command = new SqlCommand( + "SELECT SiteId, DisplayName, IsRetired FROM master.Site ORDER BY DisplayName, SiteId", connection); + await using var reader = await command.ExecuteReaderAsync(ct); + var rows = new List(); + while (await reader.ReadAsync(ct)) + rows.Add(new Site(reader.GetGuid(0), reader.GetString(1), reader.GetBoolean(2))); + return rows; + } + + public async Task> ListLinesAsync(Guid siteId, CancellationToken ct = default) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(ct); + await using var command = new SqlCommand( + "SELECT ProductionLineId, SiteId, DisplayName, IsRetired FROM master.ProductionLine WHERE SiteId=@site ORDER BY DisplayName, ProductionLineId", connection); + command.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId; + await using var reader = await command.ExecuteReaderAsync(ct); + var rows = new List(); + while (await reader.ReadAsync(ct)) + rows.Add(new ProductionLine(reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetBoolean(3))); + return rows; + } + + public async Task> ListMachinesAsync(Guid siteId, CancellationToken ct = default) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(ct); + await using var command = new SqlCommand( + "SELECT MachineId, SiteId, DisplayName, IsRetired FROM master.Machine WHERE SiteId=@site ORDER BY DisplayName, MachineId", connection); + command.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId; + await using var reader = await command.ExecuteReaderAsync(ct); + var rows = new List(); + while (await reader.ReadAsync(ct)) + rows.Add(new Machine(reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetBoolean(3))); + return rows; + } + + public Task RegisterSiteAsync(string name, CancellationToken ct = default) + => RegisterAsync("Site", "SiteId", null, name, ct); + + public Task RegisterLineAsync(Guid siteId, string name, CancellationToken ct = default) + => RegisterAsync("ProductionLine", "ProductionLineId", siteId, name, ct); + + public Task RegisterMachineAsync(Guid siteId, string name, CancellationToken ct = default) + => RegisterAsync("Machine", "MachineId", siteId, name, ct); + + private async Task RegisterAsync(string table, string idColumn, Guid? siteId, string name, CancellationToken ct) + { + // Identifiers are selected exclusively from the fixed methods above. + if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 200) + throw new ArgumentException("Display name must contain 1 to 200 characters.", nameof(name)); + if (siteId == Guid.Empty) throw new ArgumentException("Site ID is required.", nameof(siteId)); + name = name.Trim(); + var id = Guid.NewGuid(); + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(ct); + await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(IsolationLevel.Serializable, ct); + // Serializable range locking prevents two concurrent registrations of the same + // normalized display name within a site (or globally for sites). + var predicate = siteId.HasValue ? "SiteId=@site AND " : ""; + await using (var check = new SqlCommand( + $"SELECT COUNT_BIG(*) FROM master.{table} WITH (UPDLOCK, HOLDLOCK) WHERE {predicate}UPPER(LTRIM(RTRIM(DisplayName)))=UPPER(@name)", + connection, transaction)) + { + check.Parameters.Add("@name", SqlDbType.NVarChar, 200).Value = name; + if (siteId.HasValue) check.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value; + if ((long)(await check.ExecuteScalarAsync(ct) ?? 0L) != 0) + throw new InvalidOperationException("A record with this display name already exists in the selected scope."); + } + if (siteId.HasValue) + { + await using var parent = new SqlCommand( + "SELECT COUNT_BIG(*) FROM master.Site WITH (UPDLOCK, HOLDLOCK) WHERE SiteId=@site AND IsRetired=0", + connection, transaction); + parent.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value; + if ((long)(await parent.ExecuteScalarAsync(ct) ?? 0L) != 1) + throw new InvalidOperationException("The selected site does not exist or is retired."); + } + await using (var insert = new SqlCommand( + $"INSERT master.{table} ({idColumn}, {(siteId.HasValue ? "SiteId, " : "")}DisplayName) VALUES (@id, {(siteId.HasValue ? "@site, " : "")}@name)", + connection, transaction)) + { + insert.Parameters.Add("@id", SqlDbType.UniqueIdentifier).Value = id; + insert.Parameters.Add("@name", SqlDbType.NVarChar, 200).Value = name; + if (siteId.HasValue) insert.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value; + await insert.ExecuteNonQueryAsync(ct); + } + await transaction.CommitAsync(ct); + return id; + } +} From 6d7c0b964b31c3916b7b48cf96109f394ded479d Mon Sep 17 00:00:00 2001 From: Ajay Bilgaiyan Date: Wed, 11 Nov 2026 23:05:41 +0530 Subject: [PATCH 02/84] FC2-COM-001: keep registration store internal until transactional authorization is integrated --- .../MasterData/MachineAdministrationStore.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs index 5d7e42ef..456cd226 100644 --- a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs +++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs @@ -5,10 +5,10 @@ namespace FactoryConnect.Sql.MasterData; /// /// SQL-backed site, line and machine inventory. Registration must be called only -/// after the HTTP boundary has independently authorized an administrator. +/// after an audited, transaction-bound SQL authorization command has verified the actor. /// No runtime configuration, acquisition or activation is performed here. /// -public sealed class MachineAdministrationStore(string connectionString) +internal sealed class MachineAdministrationStore(string connectionString) { public sealed record Site(Guid SiteId, string DisplayName, bool IsRetired); public sealed record ProductionLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired); @@ -55,7 +55,7 @@ public async Task> ListMachinesAsync(Guid siteId, Cancell return rows; } - public Task RegisterSiteAsync(string name, CancellationToken ct = default) + // Internal-only until the write-authority integration can prove permission,\n // operation identity and audit in the same SQL transaction.\n public Task RegisterSiteAsync(string name, CancellationToken ct = default) => RegisterAsync("Site", "SiteId", null, name, ct); public Task RegisterLineAsync(Guid siteId, string name, CancellationToken ct = default) From ace2010a57b36c75e22c5e026c4affc0e3c4c25f Mon Sep 17 00:00:00 2001 From: Ajay Bilgaiyan Date: Wed, 11 Nov 2026 23:09:58 +0530 Subject: [PATCH 03/84] FC2-COM-001: add authenticated read-only machine directory screen --- .../MachineAdministrationPage.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/presentation/MachineAdministrationPage.tsx diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx new file mode 100644 index 00000000..c1084adc --- /dev/null +++ b/src/presentation/MachineAdministrationPage.tsx @@ -0,0 +1,71 @@ +import { useEffect, useMemo, useState } from "react"; +import { readAllOperatorEvidenceMachines, type OperatorEvidenceMachine } from "../api/evidence/operator-evidence-machines-client.ts"; + +/** + * First machine-administration surface: authenticated, read-only discovery. + * This is an evidence-authorized subset, not a complete administrative inventory. + * Registration controls will be enabled only after audited SQL write authority exists. + */ +export function MachineAdministrationPage({ sessionBearer, baseAddress }: { + readonly sessionBearer: string | null; + readonly baseAddress: string; +}) { + const [revision, setRevision] = useState(0); + const [search, setSearch] = useState(""); + const [state, setState] = useState< + { kind: "loading" | "signIn" | "unavailable" } | + { kind: "ready"; bearer: string; machines: readonly OperatorEvidenceMachine[]; complete: boolean } + >({ kind: "loading" }); + + useEffect(() => { + const controller = new AbortController(); + setState({ kind: "loading" }); + if (!sessionBearer) { + setState({ kind: "signIn" }); + return () => controller.abort(); + } + void readAllOperatorEvidenceMachines(baseAddress, sessionBearer, controller.signal) + .then(result => { + if (!controller.signal.aborted) + setState({ kind: "ready", bearer: sessionBearer, machines: result.machines, complete: result.isComplete }); + }) + .catch(() => { if (!controller.signal.aborted) setState({ kind: "unavailable" }); }); + return () => controller.abort(); + }, [baseAddress, sessionBearer, revision]); + + const current = state.kind === "ready" && state.bearer !== sessionBearer + ? { kind: "signIn" as const } : state; + const matches = useMemo(() => current.kind === "ready" ? current.machines.filter(machine => + machine.displayName.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) || + machine.machineId.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) || + machine.siteId.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())) : [], + [current, search]); + + return
+

Machine administration

+

Authorized machine discovery — read only. This list includes only machines for which your session has + commissioning evidence access. It is not the complete registered machine inventory or proof of live connectivity.

+

Site, production-line and machine registration is not yet enabled. No configuration or acquisition changes can be made here.

+ + {current.kind === "loading" &&

Checking authorized machines…

} + {current.kind === "signIn" &&

Sign in to view authorized machines.

} + {current.kind === "unavailable" &&

Machine discovery is unavailable. No cached inventory is shown.

} + {current.kind === "ready" && <> + {!current.complete &&

The authorized machine list is incomplete. Counts below are partial.

} + + setSearch(event.currentTarget.value)} /> +

{matches.length} matching machines from {current.machines.length} returned.

+
+ + {matches.map(machine => + + + )} +
Machine nameMachine IDSite ID
{machine.displayName}{machine.machineId}{machine.siteId}
+
+ {matches.length === 0 &&

No authorized machines match the current search.

} + } +
; +} From ccdee06e75d72c43587192a432f6aba5ab4dd795 Mon Sep 17 00:00:00 2001 From: Ajay Bilgaiyan Date: Wed, 11 Nov 2026 23:10:03 +0530 Subject: [PATCH 04/84] FC2-COM-001: register machine administration route --- src/routing/application-route.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/routing/application-route.ts b/src/routing/application-route.ts index 9e0563af..8d061f06 100644 --- a/src/routing/application-route.ts +++ b/src/routing/application-route.ts @@ -1,6 +1,7 @@ export type ApplicationRoute = | { kind: "supervisorPreview" } | { kind: "commissioning" } + | { kind: "machineAdministration" } | { kind: "operatorEvidence" } | { kind: "overview"; search: string } | { kind: "fleet" } @@ -19,6 +20,7 @@ export const applicationBasePath = "/"; export function parseApplicationRoute(pathname: string, search = ""): ApplicationRoute { if (pathname === "/supervisor-preview") return { kind: "supervisorPreview" }; if (pathname === "/operator-evidence") return { kind: "operatorEvidence" }; + if (pathname === "/admin/machines") return { kind: "machineAdministration" }; if (pathname === "/commissioning") return { kind: "commissioning" }; if (pathname === "/overview") return { kind: "overview", search }; if (pathname === "/fleet") return { kind: "fleet" }; @@ -91,6 +93,7 @@ export function routePath(route: Exclude switch (route.kind) { case "supervisorPreview": return "/supervisor-preview"; case "commissioning": return "/commissioning"; + case "machineAdministration": return "/admin/machines"; case "operatorEvidence": return "/operator-evidence"; case "overview": return `/overview${route.search}`; case "fleet": return "/fleet"; From 216e384d69d899d7806373fbc0e6b1f44dc524b9 Mon Sep 17 00:00:00 2001 From: Ajay Bilgaiyan Date: Wed, 11 Nov 2026 23:10:09 +0530 Subject: [PATCH 05/84] FC2-COM-001: expose authorized machine directory in dashboard navigation --- src/App.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 34702bbf..826899a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense } from "react"; +import { MachineAdministrationPage } from "./presentation/MachineAdministrationPage.tsx"; import { CommissioningPage } from "./presentation/CommissioningPage.tsx"; import { OperatorEvidencePage } from "./presentation/OperatorEvidencePage.tsx"; import { RetainedMachineEvidence } from "./presentation/RetainedMachineEvidence.tsx"; @@ -107,6 +108,7 @@ export function App({ runtime }: AppProps) {