|
| 1 | +import { createProjectJourneyMockRepository } from "../toolbox/project-journey/project-journey-mock-repository.js"; |
| 2 | + |
| 3 | +const AUDIT_FIELDS = ["createdAt", "updatedAt", "createdByType", "updatedByType"]; |
| 4 | + |
| 5 | +class AdminDbViewer { |
| 6 | + constructor(documentRef = document) { |
| 7 | + this.document = documentRef; |
| 8 | + this.repository = createProjectJourneyMockRepository(); |
| 9 | + this.status = documentRef.querySelector("[data-admin-db-status]"); |
| 10 | + this.diagnostics = documentRef.querySelector("[data-admin-db-diagnostics]"); |
| 11 | + this.relationships = documentRef.querySelector("[data-admin-db-relationships]"); |
| 12 | + this.tablesRoot = documentRef.querySelector("[data-admin-db-tables]"); |
| 13 | + } |
| 14 | + |
| 15 | + start() { |
| 16 | + this.repository.openProject("demo-project"); |
| 17 | + const tables = this.repository.getTables(); |
| 18 | + this.renderDiagnostics(tables); |
| 19 | + this.renderRelationships(tables); |
| 20 | + this.renderTables(tables); |
| 21 | + if (this.status) { |
| 22 | + this.status.textContent = "Read-only mock DB dump loaded for Demo Project."; |
| 23 | + } |
| 24 | + } |
| 25 | + |
| 26 | + createElement(tagName, options = {}) { |
| 27 | + const element = this.document.createElement(tagName); |
| 28 | + if (options.className) { |
| 29 | + element.className = options.className; |
| 30 | + } |
| 31 | + if (options.text !== undefined) { |
| 32 | + element.textContent = options.text; |
| 33 | + } |
| 34 | + return element; |
| 35 | + } |
| 36 | + |
| 37 | + recordId(record) { |
| 38 | + return record.itemId || record.templateId || record.id || record.name || "record"; |
| 39 | + } |
| 40 | + |
| 41 | + formatValue(value) { |
| 42 | + if (Array.isArray(value)) { |
| 43 | + return value.join(", "); |
| 44 | + } |
| 45 | + if (value && typeof value === "object") { |
| 46 | + return JSON.stringify(value); |
| 47 | + } |
| 48 | + if (value === undefined || value === null || value === "") { |
| 49 | + return "(empty)"; |
| 50 | + } |
| 51 | + return String(value); |
| 52 | + } |
| 53 | + |
| 54 | + tableFields(records) { |
| 55 | + const fields = []; |
| 56 | + records.forEach((record) => { |
| 57 | + Object.keys(record).forEach((field) => { |
| 58 | + if (!fields.includes(field)) { |
| 59 | + fields.push(field); |
| 60 | + } |
| 61 | + }); |
| 62 | + }); |
| 63 | + return fields; |
| 64 | + } |
| 65 | + |
| 66 | + renderTable(tableName, records) { |
| 67 | + const details = this.createElement("details", { |
| 68 | + className: "vertical-accordion", |
| 69 | + }); |
| 70 | + details.open = true; |
| 71 | + details.dataset.adminDbTable = tableName; |
| 72 | + |
| 73 | + const summary = this.createElement("summary", { |
| 74 | + text: `${tableName} (${records.length} records)`, |
| 75 | + }); |
| 76 | + const body = this.createElement("div", { |
| 77 | + className: "accordion-body", |
| 78 | + }); |
| 79 | + const wrapper = this.createElement("div", { |
| 80 | + className: "table-wrapper", |
| 81 | + }); |
| 82 | + const table = this.createElement("table", { |
| 83 | + className: "data-table", |
| 84 | + }); |
| 85 | + table.setAttribute("aria-label", `${tableName} records`); |
| 86 | + |
| 87 | + const fields = this.tableFields(records); |
| 88 | + const head = this.createElement("thead"); |
| 89 | + const headerRow = this.createElement("tr"); |
| 90 | + fields.forEach((field) => { |
| 91 | + headerRow.append(this.createElement("th", { text: field })); |
| 92 | + }); |
| 93 | + head.append(headerRow); |
| 94 | + |
| 95 | + const tableBody = this.createElement("tbody"); |
| 96 | + records.forEach((record) => { |
| 97 | + const row = this.createElement("tr"); |
| 98 | + row.dataset.adminDbRecord = this.recordId(record); |
| 99 | + fields.forEach((field) => { |
| 100 | + row.append(this.createElement("td", { text: this.formatValue(record[field]) })); |
| 101 | + }); |
| 102 | + tableBody.append(row); |
| 103 | + }); |
| 104 | + |
| 105 | + table.append(head, tableBody); |
| 106 | + wrapper.append(table); |
| 107 | + body.append(wrapper); |
| 108 | + details.append(summary, body); |
| 109 | + return details; |
| 110 | + } |
| 111 | + |
| 112 | + renderTables(tables) { |
| 113 | + this.tablesRoot.replaceChildren(); |
| 114 | + Object.keys(tables) |
| 115 | + .sort() |
| 116 | + .forEach((tableName) => { |
| 117 | + this.tablesRoot.append(this.renderTable(tableName, tables[tableName])); |
| 118 | + }); |
| 119 | + } |
| 120 | + |
| 121 | + auditFindings(tables) { |
| 122 | + const findings = []; |
| 123 | + Object.entries(tables).forEach(([tableName, records]) => { |
| 124 | + records.forEach((record) => { |
| 125 | + AUDIT_FIELDS.forEach((field) => { |
| 126 | + if (!Object.hasOwn(record, field)) { |
| 127 | + findings.push(`${tableName}.${this.recordId(record)} is missing ${field}.`); |
| 128 | + } |
| 129 | + }); |
| 130 | + }); |
| 131 | + }); |
| 132 | + return findings; |
| 133 | + } |
| 134 | + |
| 135 | + relationshipsForTables(tables) { |
| 136 | + const noteTypeIds = new Set(tables.project_journey_note_types.map((type) => type.id)); |
| 137 | + const noteIds = new Set(tables.project_journey_notes.map((note) => note.id)); |
| 138 | + const activeTemplateIds = new Set( |
| 139 | + tables.project_journey_templates |
| 140 | + .filter((template) => template.isActive) |
| 141 | + .map((template) => template.templateId), |
| 142 | + ); |
| 143 | + return [ |
| 144 | + { |
| 145 | + name: "project_journey_notes.typeId -> project_journey_note_types.id", |
| 146 | + checked: tables.project_journey_notes.length, |
| 147 | + missing: tables.project_journey_notes.filter((note) => !noteTypeIds.has(note.typeId)), |
| 148 | + }, |
| 149 | + { |
| 150 | + name: "project_journey_items.noteId -> project_journey_notes.id", |
| 151 | + checked: tables.project_journey_items.length, |
| 152 | + missing: tables.project_journey_items.filter((item) => !noteIds.has(item.noteId)), |
| 153 | + }, |
| 154 | + { |
| 155 | + name: "system project_journey_items.templateId -> active project_journey_templates.templateId", |
| 156 | + checked: tables.project_journey_items.filter((item) => item.createdByType === "system").length, |
| 157 | + missing: tables.project_journey_items.filter( |
| 158 | + (item) => item.createdByType === "system" && !activeTemplateIds.has(item.templateId), |
| 159 | + ), |
| 160 | + }, |
| 161 | + { |
| 162 | + name: "project_journey_activity.noteId -> project_journey_notes.id", |
| 163 | + checked: tables.project_journey_activity.length, |
| 164 | + missing: tables.project_journey_activity.filter((activity) => !noteIds.has(activity.noteId)), |
| 165 | + }, |
| 166 | + ]; |
| 167 | + } |
| 168 | + |
| 169 | + tableBleedFindings(tables) { |
| 170 | + const notesById = new Map(tables.project_journey_notes.map((note) => [note.id, note])); |
| 171 | + const findings = []; |
| 172 | + tables.project_journey_items.forEach((item) => { |
| 173 | + const note = notesById.get(item.noteId); |
| 174 | + if (note && note.projectId !== item.projectId) { |
| 175 | + findings.push(`${item.itemId} projectId ${item.projectId} does not match note ${note.id} projectId ${note.projectId}.`); |
| 176 | + } |
| 177 | + }); |
| 178 | + tables.project_journey_activity.forEach((activity) => { |
| 179 | + const note = notesById.get(activity.noteId); |
| 180 | + if (note && note.projectId !== activity.projectId) { |
| 181 | + findings.push(`${activity.id} projectId ${activity.projectId} does not match note ${note.id} projectId ${note.projectId}.`); |
| 182 | + } |
| 183 | + }); |
| 184 | + return findings; |
| 185 | + } |
| 186 | + |
| 187 | + renderList(messages, dataName) { |
| 188 | + const list = this.createElement("ul"); |
| 189 | + list.dataset[dataName] = ""; |
| 190 | + messages.forEach((message) => { |
| 191 | + list.append(this.createElement("li", { text: message })); |
| 192 | + }); |
| 193 | + return list; |
| 194 | + } |
| 195 | + |
| 196 | + renderDiagnostics(tables) { |
| 197 | + this.diagnostics.replaceChildren(); |
| 198 | + const auditFindings = this.auditFindings(tables); |
| 199 | + const bleedFindings = this.tableBleedFindings(tables); |
| 200 | + const auditSummary = auditFindings.length |
| 201 | + ? auditFindings |
| 202 | + : ["All Project Journey mock DB tables include createdAt, updatedAt, createdByType, and updatedByType."]; |
| 203 | + const bleedSummary = bleedFindings.length ? bleedFindings : ["No table bleed detected."]; |
| 204 | + this.diagnostics.append( |
| 205 | + this.renderList(auditSummary, "adminDbAuditFindings"), |
| 206 | + this.renderList(bleedSummary, "adminDbBleedFindings"), |
| 207 | + ); |
| 208 | + } |
| 209 | + |
| 210 | + renderRelationships(tables) { |
| 211 | + this.relationships.replaceChildren(); |
| 212 | + const relationshipRows = this.relationshipsForTables(tables); |
| 213 | + const missingLinks = relationshipRows.flatMap((relationship) => |
| 214 | + relationship.missing.map((record) => `${relationship.name} missing for ${this.recordId(record)}.`), |
| 215 | + ); |
| 216 | + const relationshipList = this.createElement("ul"); |
| 217 | + relationshipList.dataset.adminDbRelationshipSummary = ""; |
| 218 | + relationshipRows.forEach((relationship) => { |
| 219 | + const linkedCount = relationship.checked - relationship.missing.length; |
| 220 | + relationshipList.append( |
| 221 | + this.createElement("li", { |
| 222 | + text: `${relationship.name}: ${linkedCount}/${relationship.checked} records linked.`, |
| 223 | + }), |
| 224 | + ); |
| 225 | + }); |
| 226 | + this.relationships.append(relationshipList); |
| 227 | + this.relationships.append( |
| 228 | + this.renderList(missingLinks.length ? missingLinks : ["No missing links detected."], "adminDbMissingLinks"), |
| 229 | + ); |
| 230 | + } |
| 231 | +} |
| 232 | + |
| 233 | +new AdminDbViewer().start(); |
0 commit comments