Architecture, standards, and design patterns for the OpenCable platform. Current as of June 2026.
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:
transaction_logs| Layer | Technology | Notes |
|---|---|---|
| Runtime | Node.js v24 | ES module server (type: "module" in package.json) |
| Web framework | Express 4 | REST API + static file serving |
| Database | MySQL 8 (DigitalOcean Managed Database) | mysql2/promise driver; one pool per tenant; SSL enabled |
| Auth | bcrypt + header tokens | Header-based: X-User-ID, X-Tenant-ID |
| Security | Helmet.js + CSP | script-src 'self' — no CDN scripts |
| Frontend | Vanilla JS ES Modules | No bundler, no framework, no transpilation |
| CSS | Plain CSS custom properties | 5 stylesheet files, dark-theme variables |
| Charts | Chart.js v4 (local UMD) | Served from src/utils/chart.umd.min.js |
| Passwords | bcryptjs | Hashed at rest; compared server-side only |
| Environment | dotenv | .env for DB credentials and PORT |
import / export — no CommonJS require() on the frontendlet _allRows = []) replace global stateonclick attributes — all event listeners are attached programmatically<tbody> for table row actions — one listener per table, not one per rowsafeFetch() — never raw fetch() in screen modulesENUM types — use VARCHAR(N) with a comment listing valid values (Oracle compatibility)execute() not query() for SQL injection safetynull not undefined for nullable columns — mysql2 rejects undefinedisNaN(req.params.id) guard — path-to-regexp (new version) does not support inline regex/active, /types) must be registered before wildcard routes (/:id)packages is a MySQL reserved word — always backtick it in SQL strings (not in JS template literals)| File | Purpose |
|---|---|
| main.css | CSS custom properties (dark-theme color palette), element resets, base typography, button/input/modal classes |
| logins.css | Login overlay, login card, login form styling |
| nav.css | Sidebar shell, accordion nav groups, user-context dropdowns, sidebar footer |
| screens.css | Screen-header, table-container, data-table, action-cell, modal-overlay, form-group, form-row, form-col, form-label, form-input |
| reports.css | Report layouts, @media print isolation, PDF export CSS |
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.
\`table\` for SQL inside template literals.
Authentication is header-based, stateless. There are no server-side sessions or cookies.
sessionStorage.currentUserX-User-ID (the user's numeric database ID)users table, and populates req.user for every requestsessionStorage — the user is effectively logged outOne Node.js process serves multiple tenants. Each tenant has its own isolated MariaDB database.
?tenant=xxx URL parameter on first loadsessionStorage.tenantId and sent as X-Tenant-ID on every requestcontextLoader reads X-Tenant-ID and attaches the correct pool to req.dbPool| Table | Purpose |
|---|---|
users | Login credentials, current dept_id / role_id / work_id context |
departments | Organizational units (e.g. Engineering, Operations) |
roles | Job roles (e.g. Engineer, Viewer, Admin) |
user_department_roles | Junction: maps a user to one or more (department, role) pairs |
permissions | Named permission strings (e.g. buildings.manage) |
role_permissions | Junction: maps roles to their permitted actions |
screens | Registered screen routes with module assignment and sort order |
department_screens | Junction: which screens are available to each department |
Permissions are enforced at two independent levels — both must pass:
| Level | Mechanism | Failure |
|---|---|---|
| Server (authoritative) | checkPermission("perm.name") middleware on every route | HTTP 403 — request blocked |
| Client (UX) | hasPermission("perm.name") — reads sessionStorage.userPermissions | Buttons/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.
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.
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.
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.
| Table | Key Columns | Notes |
|---|---|---|
users | id, username, password_hash, full_name, enabled, dept_id, role_id, work_id | dept/role/work are last-used context, persisted on every context switch |
departments | id, name, description | |
roles | id, name, description | |
user_department_roles | user_id, department_id, role_id | One row per valid (user, dept, role) combination |
permissions | id, name, description | e.g. buildings.manage, users.view |
role_permissions | role_id, permission_id | |
modules | id, name, code, sort_order, enabled | Used to group screens in the sidebar |
screens | id, route, name, description, module_id, enabled, sortseq | route = JS module key; no icon or permission columns |
department_screens | department_id, screen_id | Controls what appears in each department's sidebar |
packages | id, type_id, name, title, author_id, owning_department_id, lifecycle_status, reviewed_by, approved_by | Work packages (formerly "workorders") |
package_types | id, name, code, description | e.g. ECN, MOC, RFI |
transaction_logs | id, user_id, username, action, table_name, record_id, record_name, details, execution_status, proposed_payload, workorder_name | All create/update/delete operations |
InnoDB, utf8mb4 charsetENUM types — VARCHAR with comment lists valid valuescreated_at / updated_at timestamps on every table with DEFAULT current_timestamp() and ON UPDATE current_timestamp()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 Flag | Meaning |
|---|---|
is_raceway | Valid as a raceway (appears in raceway screens) |
is_segment | Traversable by routing algorithms (Dijkstra, DFS) |
is_endpoint | Valid cable FROM/TO destination (CUBICLE, BREAKER, TBOX) |
is_container | Has child raceways (MCC, SWGR, LPANEL) |
is_child | Must exist under a parent; hidden from top-level Add dropdown |
is_subid | Show Sub ID input field in UI |
is_bus | Instance-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.
| Column | Notes |
|---|---|
from_raceway_id / to_raceway_id | Declared endpoints — should reference is_endpoint=1 raceways |
routing_status | UNROUTED → PARTIAL → ROUTED → ALTERED → APPROVED |
installation_status | NOT_INSTALLED → PARTIAL → INSTALLED → VERIFIED |
total_length_ft | App-maintained sum of segment lengths (triggers retired; recalcTotalLength() helper used instead) |
is_autoroute_locked | Set on any manual insert/replace; auto-route requires confirmation to overwrite |
installation_year | Used for cable aging analysis against cable_types.design_life_years |
Stored in cable_segment_routing. Each row is one step in the cable's path:
| seq value | Meaning |
|---|---|
| 1 | FROM endpoint (the cable's declared from_raceway_id) |
| 10, 20, 30… | Intermediate raceway segments |
| 9999 | TO 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.
Stored in cable_types. Key analysis fields:
| Column | Notes |
|---|---|
service_level | MV | LV | CP | DC | FIBER | COAX | OTHER — stored, with runtime derivation fallback from voltage_rating + cable_category |
temp_rating_c | Insulation temperature rating (60, 75, or 90°C) |
design_life_years | Expected service life — used with installation_year to trigger aging warnings |
aging_factor | 0–1 condition assessment multiplier; set from field inspection / megger testing |
resistance_ac / resistance_dc | Ω/1000ft — used for voltage drop calculations |
The cable_problems table stores detected issues per cable. Problem types include:
| Problem Type | Trigger |
|---|---|
| VOLTAGE_DROP_WARN | Calculated voltage drop > warning threshold |
| VOLTAGE_DROP_CRIT | Calculated voltage drop > critical threshold |
| AGING_WARNING | Cable age ≥ 80% of design life |
| AGING_CRITICAL | Cable age ≥ 100% of design life, or aging_factor < 0.75 |
| NO_TYPE | Cable has no cable_type_id assigned |
| NO_ROUTE | Cable is UNROUTED with no segments |
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.
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.
| Field | Notes |
|---|---|
name | Unique package identifier (e.g. ECN-1001, MOC-0042) |
type_id | FK to package_types — governs workflow rules |
author_id | Creator — separation of duties prevents self-approval |
owning_department_id | Department ultimately responsible |
current_queue_department_id | Department currently holding the ball — shifts to Field during construction |
reviewed_by / approved_by | Independent checker and authorizing manager |
reference_file_url | Link to SharePoint/Drive for signed PDF documentation |
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.
requireWorkorder() — they write directly to the database. See §10.
Every successful create, update, and delete operation calls logTransaction(req, options) from src/utils/logger.js. It writes one row to transaction_logs capturing:
| Column | Source |
|---|---|
| user_id, username | req.user (from contextLoader) |
| dept_name, role_name | req.user.department_name, req.user.role_name |
| workorder_name | X-Workorder-Name header |
| action | CREATE | UPDATE | DELETE | LOGIN |
| table_name, record_id, record_name | Passed by the route handler |
| details | Human-readable description; field-level diff for UPDATEs via generateChangeLog() |
| execution_status | APPLIED (default) or PENDING (staged changes) |
| ip_address, context_info | Request IP + User-Agent (first 255 chars) |
| db_choice | Tenant ID (req.tenantId) |
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:
isRecordLocked() checks if a different package already has a PENDING entry for the same record. If so, the operation is blocked.stagePendingTransaction() updates it (net-change) rather than creating a duplicate.Produces a field-level diff string for UPDATE operations: "field: 'old' -> 'new' | field2: 'old' -> 'new'". Skips created_at and updated_at timestamps.
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.
The following electrical screens write changes directly to the database on every save — no staging, no package required:
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'.
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:
#workspace-content with a "Loading…" placeholderrouteRegistry mapimport(path)"cable-register-maintenance" → renderCableRegisterMaintenancemodule[funcName](workspaceElement)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.
routeRegistry in app.jsscreens database table, assigned to a department via department_screensTwo separate routing algorithms are available via the Link Routing screen:
| Algorithm | Route | Returns | Use case |
|---|---|---|---|
| Dijkstra (Auto-Route) | POST /api/cable-segments/:id/auto-route | Single shortest path by total length | Quick commit — one click |
| DFS (Find Paths) | GET /api/pathway-analysis/run | Multiple complete paths, ranked by length | User picks from alternatives; supports Via waypoint |
All maintenance screens follow a consistent pattern. Use src/modules/electrical/screens/bus-maintenance.js as the canonical reference.
| Class / ID | Purpose |
|---|---|
.screen-header | Flex row: title group left, controls right |
.header-title-group | Contains <h2> and optional bulk-delete button |
.controls | Filter selects, Refresh button, + Add button |
.table-container | Scrollable wrapper around the data table |
.data-table | The <table> element |
class="col-actions" | On the Actions <th> |
.modal-overlay | Full-screen dimmed overlay for modals |
.modal-content | The white modal card |
.modal-header | Modal title + close button (.btn-close with ×) |
.modal-actions | Cancel + Save button row at bottom of modal |
.form-row / .form-col | Side-by-side field layout |
.form-group | Full-width field |
.form-label / .form-input | Label and input/select styling |
Row action buttons use data-action and data-id attributes. A single event listener on the <tbody> handles all row actions via event delegation:
One modal serves both create and edit. A hidden <input type="hidden" id="record-id"> field determines which path runs on save:
/api/resource (create)/api/resource/:id (update)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.
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.
| File | Exports | Purpose |
|---|---|---|
| utils/dom.js | dedent(), escHtml() | Template literal indent stripping; HTML entity escaping |
| utils/toast.js | showToast(msg, type) | Non-blocking toast notifications — type: success | error | warning | info |
| utils/sort.js | makeTableSortable(tableEl) | Click-to-sort on any <th> with a .sort-indicator span; cycles asc → desc → original |
| utils/table-select.js | makeTableSelectable(tableEl) | Row checkboxes + select-all; used for bulk delete screens |
| utils/logger.js | logTransaction(), stagePendingTransaction(), isRecordLocked(), generateChangeLog() | All audit and staged-change server utilities |
| utils/history-panel.js | renderHistoryPanel() | Embeds the transaction log viewer into a modal for a specific record |
| utils/record-documents-panel.js | renderRecordDocumentsPanel() | Embeds the document links panel for any record type |
| utils/cable-analysis.js | runCableAnalysis() | Executes voltage drop, aging, and routing status analysis; writes to cable_problems |
| utils/cable-integrity.js | Integrity checks | Data quality checks for cable register completeness |
| utils/fill-analysis.js | Fill calculation | Raceway fill percentage calculations per NEC methods |
| utils/raceway-links.js | Link rendering | Renders raceway connection topology cards |
| utils/add-to-list.js | addToList() | User list management helper |
| utils/resize.js | Resize observer | Responsive panel resize handling |
| utils/chart.umd.min.js | Chart.js v4 | Local UMD bundle — served from same origin to satisfy CSP script-src 'self' |
SMECO uses the browser's native print engine. No third-party PDF library is needed.
@media print rules in reports.css set visibility: hidden on the sidebar, header, and all controls.rpt-container is un-hidden; absolute positioning snaps it to the top-left of the printed page#print-filter-summary block becomes visible only in print — permanently records active filters on the PDFwindow.print()CSV is generated entirely in the browser without a backend call:
Blob with MIME type text/csv<a> tag with a Blob URL is programmatically clicked, triggering the browser's download promptThe export function name must be the route string converted to PascalCase: "my-screen" → renderMyScreen.
route, name, description, module_id, enabled, sortseq. There is NO icon column, NO label column, and NO permission column.
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.
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.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Auto-injected by DO | Full 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_NAME | Local dev fallback | Used when DATABASE_URL is absent. Set in .env for local development — never commit this file. |
DB_SSL | Set to true on DO | Enables SSL on the mysql2 pool. Uses rejectUnauthorized: false to accept DO's self-signed CA. |
PREVIEW_MODE | Optional | Set to true to enable read-only preview mode. See below. |
PORT | Optional | HTTP port. Defaults to 3000. DO sets this automatically. |
ALLOWED_ORIGIN | Optional | CORS origin whitelist. Defaults to http://localhost:3000 for local dev. |
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.
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.
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.
| Effect | Implementation |
|---|---|
| Login screen shows amber "Preview Release" banner | Always rendered — HTML/CSS in index.html and src/assets/styles/logins.css |
POST/PUT/DELETE /api/documents returns 503 | previewBlock middleware in src/routes/documents.js — checked at module load time |
| File upload returns 503 | Local disk writes are not viable on App Platform ephemeral containers |
GET /api/documents/files returns [] | No disk access; avoids directory-not-found errors |
| Domain | Points To | Notes |
|---|---|---|
| opencable.org | OpenCable App Platform web service | A records managed by DO (15.197.142.173, 3.33.152.147). GoDaddy nameservers pointed to DO. |
| openfacility.org | OpenFacility static site (DO App Platform) | Separate static site deployment. SSL cert provisioned by Let's Encrypt via DO. |
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.