← Documentation Index

OpenCable — Application Design Document

Architecture, standards, and design patterns for the OpenCable platform. Current as of June 2026.

Table of Contents

  1. Executive Summary
  2. Technology Stack
  3. Development Standards & Principles
  4. Application Architecture
  5. Role-Based Access Control (RBAC)
  6. Database Schema Overview
  7. Electrical Module Data Model
  8. Package (Work Order) Management
  9. Transaction Logger & Audit Trail
  10. Direct-Write Screens
  11. Navigation & Routing Engine
  12. Maintenance Screen Design Pattern
  13. Shared Utility Modules
  14. Report & Export Engine
  15. How to Create & Publish a New Screen
  16. Process Flowcharts
  17. Deployment & Environment

1. Executive Summary

OpenCable is an enterprise cable and electrical infrastructure management application. It is a Single-Page Application (SPA) with a Node.js/Express REST API backend and a Vanilla JavaScript frontend. There is no client-side framework — all UI is built with ES modules, template literals, and standard DOM APIs.

The application manages:

2. Technology Stack

LayerTechnologyNotes
RuntimeNode.js v24ES module server (type: "module" in package.json)
Web frameworkExpress 4REST API + static file serving
DatabaseMySQL 8 (DigitalOcean Managed Database)mysql2/promise driver; one pool per tenant; SSL enabled
Authbcrypt + header tokensHeader-based: X-User-ID, X-Tenant-ID
SecurityHelmet.js + CSPscript-src 'self' — no CDN scripts
FrontendVanilla JS ES ModulesNo bundler, no framework, no transpilation
CSSPlain CSS custom properties5 stylesheet files, dark-theme variables
ChartsChart.js v4 (local UMD)Served from src/utils/chart.umd.min.js
PasswordsbcryptjsHashed at rest; compared server-side only
Environmentdotenv.env for DB credentials and PORT

3. Development Standards & Principles

JavaScript

SQL Conventions

CSS Architecture

FilePurpose
main.cssCSS custom properties (dark-theme color palette), element resets, base typography, button/input/modal classes
logins.cssLogin overlay, login card, login form styling
nav.cssSidebar shell, accordion nav groups, user-context dropdowns, sidebar footer
screens.cssScreen-header, table-container, data-table, action-cell, modal-overlay, form-group, form-row, form-col, form-label, form-input
reports.cssReport layouts, @media print isolation, PDF export CSS

HTML Template Pattern

Screen modules inject their entire HTML structure via workspace.innerHTML = dedent(`...`) exactly once on load. The dedent() utility (in src/utils/dom.js) strips leading indentation from template literals so the rendered HTML is clean.

⚠️ Template literal backtick conflict: SQL identifiers backtick-quoted inside a JS template literal will terminate the string. Use regular string concatenation or escape as \`table\` for SQL inside template literals.

4. Application Architecture

Request Flow

Browser — safeFetch(url, options)
↓ X-User-ID, X-Tenant-ID, X-Workorder-Name headers
Express — contextLoader middleware → populates req.user, req.dbPool, req.tenantId
checkPermission("perm.name") middleware → 403 if not authorized
Route handler — SQL via req.dbPool.execute()
logTransaction() — writes to transaction_logs
res.json(result) → browser

Authentication Model

Authentication is header-based, stateless. There are no server-side sessions or cookies.

Multi-Tenant Architecture

One Node.js process serves multiple tenants. Each tenant has its own isolated MariaDB database.

5. Role-Based Access Control (RBAC)

Data Model

TablePurpose
usersLogin credentials, current dept_id / role_id / work_id context
departmentsOrganizational units (e.g. Engineering, Operations)
rolesJob roles (e.g. Engineer, Viewer, Admin)
user_department_rolesJunction: maps a user to one or more (department, role) pairs
permissionsNamed permission strings (e.g. buildings.manage)
role_permissionsJunction: maps roles to their permitted actions
screensRegistered screen routes with module assignment and sort order
department_screensJunction: which screens are available to each department

Permission Enforcement

Permissions are enforced at two independent levels — both must pass:

LevelMechanismFailure
Server (authoritative)checkPermission("perm.name") middleware on every routeHTTP 403 — request blocked
Client (UX)hasPermission("perm.name") — reads sessionStorage.userPermissionsButtons/controls hidden

Permissions are fetched once after login and after every context switch via GET /api/auth/permissions, stored as a flat JSON array in sessionStorage.userPermissions.

Context Switching

Users with multiple department/role assignments can switch context via the sidebar dropdowns at any time. Switching calls POST /api/auth/set-context, which persists the new selection to the users table, then refreshes permissions and rebuilds the sidebar navigation — no page reload.

Navigation Filtering

The sidebar nav is built dynamically from GET /api/auth/nav?department_id=X&role_id=Y. The server returns only screens assigned to the user's department via department_screens. Screens are grouped by module and rendered as collapsible accordion sections, all collapsed by default.

ℹ️ Current nav behaviour: The nav query filters by department_id only. The role_id is used for permission checks but not for screen filtering — all department-assigned screens are shown regardless of role.

6. Database Schema Overview

Core Tables

TableKey ColumnsNotes
usersid, username, password_hash, full_name, enabled, dept_id, role_id, work_iddept/role/work are last-used context, persisted on every context switch
departmentsid, name, description
rolesid, name, description
user_department_rolesuser_id, department_id, role_idOne row per valid (user, dept, role) combination
permissionsid, name, descriptione.g. buildings.manage, users.view
role_permissionsrole_id, permission_id
modulesid, name, code, sort_order, enabledUsed to group screens in the sidebar
screensid, route, name, description, module_id, enabled, sortseqroute = JS module key; no icon or permission columns
department_screensdepartment_id, screen_idControls what appears in each department's sidebar
packagesid, type_id, name, title, author_id, owning_department_id, lifecycle_status, reviewed_by, approved_byWork packages (formerly "workorders")
package_typesid, name, code, descriptione.g. ECN, MOC, RFI
transaction_logsid, user_id, username, action, table_name, record_id, record_name, details, execution_status, proposed_payload, workorder_nameAll create/update/delete operations

Schema Constraints

7. Electrical Module Data Model

Physical Hierarchy

buildings — physical structures on site
↓ building_id FK
rooms — spaces within a building (fire zones, equipment rooms)
↓ raceway_rooms junction
raceways — cable trays, conduits, MCCs, switchgear, panels, endpoints
↓ raceway_connections — bidirectional topology links
cable_segment_routing — ordered path of a cable through raceways
↑ cable_id FK
cable_register — the cable itself (from/to endpoint, type, routing status)

Raceways

Raceways are the universal container for all physical routing elements. Type is determined by record_code_id FK to the record_codes table — not a separate type table.

record_codes FlagMeaning
is_racewayValid as a raceway (appears in raceway screens)
is_segmentTraversable by routing algorithms (Dijkstra, DFS)
is_endpointValid cable FROM/TO destination (CUBICLE, BREAKER, TBOX)
is_containerHas child raceways (MCC, SWGR, LPANEL)
is_childMust exist under a parent; hidden from top-level Add dropdown
is_subidShow Sub ID input field in UI
is_busInstance-level flag on the raceways row (not a type flag)

Raceway topology is stored in raceway_connections as bidirectional pairs (raceway_a_id, raceway_b_id). A duplicate guard prevents both (a,b) and (b,a) from existing.

Cable Register

ColumnNotes
from_raceway_id / to_raceway_idDeclared endpoints — should reference is_endpoint=1 raceways
routing_statusUNROUTED → PARTIAL → ROUTED → ALTERED → APPROVED
installation_statusNOT_INSTALLED → PARTIAL → INSTALLED → VERIFIED
total_length_ftApp-maintained sum of segment lengths (triggers retired; recalcTotalLength() helper used instead)
is_autoroute_lockedSet on any manual insert/replace; auto-route requires confirmation to overwrite
installation_yearUsed for cable aging analysis against cable_types.design_life_years

Cable Segment Routing

Stored in cable_segment_routing. Each row is one step in the cable's path:

seq valueMeaning
1FROM endpoint (the cable's declared from_raceway_id)
10, 20, 30…Intermediate raceway segments
9999TO endpoint (the cable's declared to_raceway_id)

Segment status values: ACTIVE REMOVED (soft-deleted, can be restored) REPLACED (superseded by a replacement at same seq).

The Approve action purges all REMOVED and REPLACED rows, resequences ACTIVE intermediates as 10/20/30…, sets routing_status = APPROVED, and sets is_autoroute_locked = 1.

Cable Types

Stored in cable_types. Key analysis fields:

ColumnNotes
service_levelMV | LV | CP | DC | FIBER | COAX | OTHER — stored, with runtime derivation fallback from voltage_rating + cable_category
temp_rating_cInsulation temperature rating (60, 75, or 90°C)
design_life_yearsExpected service life — used with installation_year to trigger aging warnings
aging_factor0–1 condition assessment multiplier; set from field inspection / megger testing
resistance_ac / resistance_dcΩ/1000ft — used for voltage drop calculations

Cable Analysis

The cable_problems table stores detected issues per cable. Problem types include:

Problem TypeTrigger
VOLTAGE_DROP_WARNCalculated voltage drop > warning threshold
VOLTAGE_DROP_CRITCalculated voltage drop > critical threshold
AGING_WARNINGCable age ≥ 80% of design life
AGING_CRITICALCable age ≥ 100% of design life, or aging_factor < 0.75
NO_TYPECable has no cable_type_id assigned
NO_ROUTECable is UNROUTED with no segments

Loadflow

Buses, generators, and loads form the electrical loadflow model. Loads link to buses via bus_id. The loadflow_cable_links and loadflow_link_cables tables connect load records to cable register entries for linker analysis.

8. Package (Work Order) Management

Work packages (formerly called "workorders") track authorized changes through a formal lifecycle. The active package is selected in the sidebar Active Package dropdown and sent as X-Workorder-Name on every API request.

Lifecycle Status

DraftReview PendingReviewedApprovedUnder ConstructionConstruction CompleteArchived

Key Fields

FieldNotes
nameUnique package identifier (e.g. ECN-1001, MOC-0042)
type_idFK to package_types — governs workflow rules
author_idCreator — separation of duties prevents self-approval
owning_department_idDepartment ultimately responsible
current_queue_department_idDepartment currently holding the ball — shifts to Field during construction
reviewed_by / approved_byIndependent checker and authorizing manager
reference_file_urlLink to SharePoint/Drive for signed PDF documentation

requireWorkorder()

Screens that require a package for changes call requireWorkorder() before write operations. It checks sessionStorage.currentUser.work_id and shows a toast error if no active package is selected.

ℹ️ Direct-write screens (buildings, rooms, raceways, cable register, cable types, buses, generators, loads) do NOT call requireWorkorder() — they write directly to the database. See §10.

9. Transaction Logger & Audit Trail

logTransaction()

Every successful create, update, and delete operation calls logTransaction(req, options) from src/utils/logger.js. It writes one row to transaction_logs capturing:

ColumnSource
user_id, usernamereq.user (from contextLoader)
dept_name, role_namereq.user.department_name, req.user.role_name
workorder_nameX-Workorder-Name header
actionCREATE | UPDATE | DELETE | LOGIN
table_name, record_id, record_namePassed by the route handler
detailsHuman-readable description; field-level diff for UPDATEs via generateChangeLog()
execution_statusAPPLIED (default) or PENDING (staged changes)
ip_address, context_infoRequest IP + User-Agent (first 255 chars)
db_choiceTenant ID (req.tenantId)

Staged Change Management

Some screens support a "stage then apply" workflow using stagePendingTransaction(). This writes a log entry with execution_status = 'PENDING' and a JSON proposed_payload without touching the live data tables. The execution engine applies PENDING logs when a package reaches Approved status.

Key behaviors:

generateChangeLog()

Produces a field-level diff string for UPDATE operations: "field: 'old' -> 'new' | field2: 'old' -> 'new'". Skips created_at and updated_at timestamps.

Frontend Audit Viewer

The renderTransactionLogs component (imported in admin screens) can be embedded in any modal to show a filtered history for a specific record name or table. Every maintenance screen that supports history shows a 📋 icon button per row.

10. Direct-Write Screens

The following electrical screens write changes directly to the database on every save — no staging, no package required:

buildingsPOST/PUT/DELETE → direct DB + logTransaction
roomsNested under buildings routes
racewaysIncluding connections
cable_registerIncluding segment routing
cable_typesMaterials catalog
tray_typesMaterials catalog
conduit_typesMaterials catalog
busesElectrical buses
generatorsGenerator records
loadsLoad records
⛔ Do NOT add requireWorkorder() or stagePendingTransaction() to these routes. The staged/pending workflow was retired for these tables. All changes are immediate and logged with execution_status = 'APPLIED'.

11. Navigation & Routing Engine

Single-Page Application Model

The application never performs a full page reload during normal use. All screen transitions are handled by navigate(route) in src/modules/app/app.js:

  1. Clears #workspace-content with a "Loading…" placeholder
  2. Looks up the route in the static routeRegistry map
  3. Dynamically imports the screen's ES module: import(path)
  4. Derives the render function name: "cable-register-maintenance"renderCableRegisterMaintenance
  5. Calls module[funcName](workspaceElement)

Route Registry

Located in the navigate() function body in src/modules/app/app.js. Every screen JS file must have a corresponding entry. Unregistered routes display a 404 panel.

⚠️ Two registrations required for every new screen:
  1. Entry in routeRegistry in app.js
  2. Row in the screens database table, assigned to a department via department_screens

Cable Routing Engine

Two separate routing algorithms are available via the Link Routing screen:

AlgorithmRouteReturnsUse case
Dijkstra (Auto-Route)POST /api/cable-segments/:id/auto-routeSingle shortest path by total lengthQuick commit — one click
DFS (Find Paths)GET /api/pathway-analysis/runMultiple complete paths, ranked by lengthUser picks from alternatives; supports Via waypoint

12. Maintenance Screen Design Pattern

All maintenance screens follow a consistent pattern. Use src/modules/electrical/screens/bus-maintenance.js as the canonical reference.

Module Structure

// 1. Imports import { safeFetch, hasPermission } from "../../app/app.js" import { showToast } from "../../../utils/toast.js" import { makeTableSortable } from "../../../utils/sort.js" import { dedent } from "../../../utils/dom.js" // 2. Module-level state let _canManage = false let _allRows = [] // 3. Entry point — called by navigate() export async function renderBusMaintenance(workspace) { _canManage = hasPermission("buildings.manage") workspace.innerHTML = buildScaffold() wireEvents() await loadData() }

HTML Scaffold

Class / IDPurpose
.screen-headerFlex row: title group left, controls right
.header-title-groupContains <h2> and optional bulk-delete button
.controlsFilter selects, Refresh button, + Add button
.table-containerScrollable wrapper around the data table
.data-tableThe <table> element
class="col-actions"On the Actions <th>
.modal-overlayFull-screen dimmed overlay for modals
.modal-contentThe white modal card
.modal-headerModal title + close button (.btn-close with ×)
.modal-actionsCancel + Save button row at bottom of modal
.form-row / .form-colSide-by-side field layout
.form-groupFull-width field
.form-label / .form-inputLabel and input/select styling

Action Buttons

Row action buttons use data-action and data-id attributes. A single event listener on the <tbody> handles all row actions via event delegation:

// Standard action buttons <button class="action-btn" data-action="edit" data-id="${r.id}" title="Edit">✏️</button> <button class="action-btn" data-action="copy" data-id="${r.id}" title="Copy">❐</button> <button class="action-btn info" data-action="logs" data-name="${r.name}" title="History">📋</button> <button class="action-btn danger" data-action="delete" data-id="${r.id}" title="Delete">&#128465;</button>

Create vs Edit Modal

One modal serves both create and edit. A hidden <input type="hidden" id="record-id"> field determines which path runs on save:

Table Sorting & Selection

After rendering rows call makeTableSortable(tableEl) from src/utils/sort.js. Clicking a sortable <th> cycles asc → desc → original. For screens with bulk delete, also call makeTableSelectable(tableEl) from src/utils/table-select.js — adds row checkboxes and a select-all header checkbox.

Client-Side Filtering

Screens with filter dropdowns cache the full dataset in a module-level variable (e.g. _allRows) and re-render with renderRows() on filter change — no additional API call. The full list is only re-fetched on Refresh.

13. Shared Utility Modules

FileExportsPurpose
utils/dom.jsdedent(), escHtml()Template literal indent stripping; HTML entity escaping
utils/toast.jsshowToast(msg, type)Non-blocking toast notifications — type: success | error | warning | info
utils/sort.jsmakeTableSortable(tableEl)Click-to-sort on any <th> with a .sort-indicator span; cycles asc → desc → original
utils/table-select.jsmakeTableSelectable(tableEl)Row checkboxes + select-all; used for bulk delete screens
utils/logger.jslogTransaction(), stagePendingTransaction(), isRecordLocked(), generateChangeLog()All audit and staged-change server utilities
utils/history-panel.jsrenderHistoryPanel()Embeds the transaction log viewer into a modal for a specific record
utils/record-documents-panel.jsrenderRecordDocumentsPanel()Embeds the document links panel for any record type
utils/cable-analysis.jsrunCableAnalysis()Executes voltage drop, aging, and routing status analysis; writes to cable_problems
utils/cable-integrity.jsIntegrity checksData quality checks for cable register completeness
utils/fill-analysis.jsFill calculationRaceway fill percentage calculations per NEC methods
utils/raceway-links.jsLink renderingRenders raceway connection topology cards
utils/add-to-list.jsaddToList()User list management helper
utils/resize.jsResize observerResponsive panel resize handling
utils/chart.umd.min.jsChart.js v4Local UMD bundle — served from same origin to satisfy CSP script-src 'self'

14. Report & Export Engine

PDF Generation — CSS Print Isolation

SMECO uses the browser's native print engine. No third-party PDF library is needed.

CSV Export — Client-Side Blob

CSV is generated entirely in the browser without a backend call:

  1. The screen maps its current filtered data array to a comma-separated string
  2. The string is wrapped in a Blob with MIME type text/csv
  3. A temporary <a> tag with a Blob URL is programmatically clicked, triggering the browser's download prompt
  4. The anchor and Blob URL are immediately revoked and removed

15. How to Create & Publish a New Screen

Step 1 — Create the screen JS module

// src/modules/electrical/screens/my-screen.js import { safeFetch } from "../../app/app.js" import { dedent } from "../../../utils/dom.js" export async function renderMyScreen(workspace) { workspace.innerHTML = dedent(` <div class="screen-header"> <div class="header-title-group"><h2>My Screen</h2></div> <div class="controls">...</div> </div>`) }

The export function name must be the route string converted to PascalCase: "my-screen"renderMyScreen.

Step 2 — Register in app.js route registry

// In navigate() in src/modules/app/app.js "my-screen": "/src/modules/electrical/screens/my-screen.js",

Step 3 — Insert screen record in the database

INSERT INTO `screens` (`route`, `name`, `description`, `module_id`, `enabled`, `sortseq`) VALUES ( 'my-screen', 'My Screen', 'Description here', (SELECT id FROM `modules` WHERE code = 'electrical'), 1, 100 ) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description);
⚠️ screens table schema: Columns are route, name, description, module_id, enabled, sortseq. There is NO icon column, NO label column, and NO permission column.

Step 4 — Assign screen to a department

Navigate to Admin → Department Screen Maintenance and assign the new screen to the appropriate department(s). The screen will automatically appear in the sidebar accordion for users in those departments on next login or context switch.

16. Process Flowcharts

Login & Navigation Initialization

Browser loads index.html → window.load → initTenant()
↓ fetch /api/public/config/:tenantId — apply branding
initApp() — check sessionStorage.currentUser
↓ not found → showLogin()
User submits login form → POST /api/auth/login
↓ 200 OK → store user in sessionStorage → initApp() again
populateSidebarDropdowns() — GET /api/auth/context/:id + GET /api/packages/active
fetchAndStorePermissions() — GET /api/auth/permissions → sessionStorage.userPermissions
loadNavigation(deptId, roleId) — GET /api/auth/nav → build accordion sidebar
navigate(screens[0].route) — auto-load first screen

Screen Navigation (SPA)

User clicks sidebar nav item
navigate("route-name") — clears workspace-content
↓ look up path in routeRegistry
dynamic import(path) → call renderFunctionName(workspace)
Screen module: workspace.innerHTML = scaffold HTML
Wire events → load data via safeFetch → render rows

Direct-Write Edit Flow

User clicks ✏️ Edit button in table row
↓ tbody event delegation captures data-action="edit", data-id
safeFetch GET /api/resource/:id → populate modal form
User modifies fields → clicks Save
safeFetch PUT /api/resource/:id with JSON payload
↓ server: checkPermission → validate → UPDATE → logTransaction(APPLIED)
200 OK → showToast("Updated") → reload table data

Cable Routing — Link Walk Flow

Select cable in Link Routing screen
↓ updateTail() seeds _tailRacewayId = from_raceway_id
refreshSidebar() → GET /api/raceways/:id/connections → show is_segment neighbors
↓ user clicks + on a segment card
addSegment() → POST /api/cable-segments/:cableId
↓ _tailRacewayId advances → sidebar refreshes with next hop neighbors
Repeat until TO endpoint reached → routing_status = ROUTED
Approve → POST /api/cable-segments/:cableId/approve → purge REMOVED/REPLACED, resequence, lock

17. Deployment & Environment

DigitalOcean App Platform

The application is deployed on DigitalOcean App Platform (Node.js web service) connected to a DO Managed MySQL cluster. All configuration is injected via environment variables — no credentials are stored in committed files.

Environment Variables

VariableRequiredDescription
DATABASE_URLAuto-injected by DOFull MySQL connection URL. DO injects this automatically when a managed DB is attached to the component. Do not set manually.
DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAMELocal dev fallbackUsed when DATABASE_URL is absent. Set in .env for local development — never commit this file.
DB_SSLSet to true on DOEnables SSL on the mysql2 pool. Uses rejectUnauthorized: false to accept DO's self-signed CA.
PREVIEW_MODEOptionalSet to true to enable read-only preview mode. See below.
PORTOptionalHTTP port. Defaults to 3000. DO sets this automatically.
ALLOWED_ORIGINOptionalCORS origin whitelist. Defaults to http://localhost:3000 for local dev.

Database Connection — DATABASE_URL

src/config/db.js checks for DATABASE_URL first and parses host, port, user, password, and database name from it. Falls back to individual env vars for local development.

DO template placeholder guard: If DATABASE_URL is manually added as a component environment variable in the DO dashboard, DO may inject the literal string ${production-database.DATABASE_URL} instead of the resolved value. The code checks for this with startsWith("${") and falls back to individual vars. Always attach the database to the component — never manually copy the URL.

PREVIEW_MODE

Set PREVIEW_MODE=true to put the application in read-only preview mode. Used on opencable.org until a production tenant database is confirmed stable.

EffectImplementation
Login screen shows amber "Preview Release" bannerAlways rendered — HTML/CSS in index.html and src/assets/styles/logins.css
POST/PUT/DELETE /api/documents returns 503previewBlock middleware in src/routes/documents.js — checked at module load time
File upload returns 503Local disk writes are not viable on App Platform ephemeral containers
GET /api/documents/files returns []No disk access; avoids directory-not-found errors
Document storage: App Platform containers have ephemeral local filesystems. Files do not persist across deployments or restarts. Production document storage will require DigitalOcean Spaces (S3-compatible) as a future enhancement.

DNS & Domains

DomainPoints ToNotes
opencable.orgOpenCable App Platform web serviceA records managed by DO (15.197.142.173, 3.33.152.147). GoDaddy nameservers pointed to DO.
openfacility.orgOpenFacility static site (DO App Platform)Separate static site deployment. SSL cert provisioned by Let's Encrypt via DO.

Deploy Process

DO App Platform auto-deploys on every push to the v26-development branch (connected in the DO app settings). Build command: npm install. Run command: node server.js.

The src/docs/ folder is served as static files at /docs/ — no separate deployment step. Pushing a new file to src/docs/samples/ makes it live at https://opencable.org/docs/samples/filename after the next auto-deploy.