File size: 16,342 Bytes
70a2f38
 
 
 
 
2a8f292
 
70a2f38
 
2a8f292
 
 
 
 
ef54c02
 
2a8f292
 
 
f25d86c
2a8f292
 
 
70a2f38
 
 
 
 
 
2759e27
 
 
d87965a
2759e27
 
 
 
 
 
 
 
 
 
 
 
d87965a
 
 
 
 
 
 
 
 
 
 
 
 
 
10250ed
82175c4
 
10250ed
 
82175c4
e32adcf
 
20da685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b784929
 
 
 
 
 
 
 
 
 
 
0b1d3dc
 
b784929
 
 
0b1d3dc
 
 
 
ff4d747
 
 
 
 
 
 
 
f157dcf
ff4d747
 
 
 
 
 
 
 
 
 
34b1698
f25d86c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70a2f38
 
67aec02
 
 
 
 
 
 
 
 
 
 
 
0ee5ba2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617b1e2
 
 
 
 
0ee5ba2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef54c02
 
 
 
 
 
617b1e2
 
 
 
 
 
 
 
 
 
 
 
 
ef54c02
 
 
 
 
 
0ee5ba2
 
 
 
 
67aec02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef54c02
 
67aec02
 
 
 
 
 
 
 
 
 
 
617b1e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67aec02
 
2a8f292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70a2f38
 
 
67aec02
70a2f38
 
 
10250ed
70a2f38
 
67aec02
 
 
ff4d747
70a2f38
0ee5ba2
 
 
2a8f292
67aec02
2a8f292
 
 
70a2f38
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
/**
 * Safe schema migrations β€” run before server start.
 * Each ALTER TABLE is caught on errno 1060 (duplicate column) so
 * it is safe to run on every boot without wiping data.
 */
const fs = require('fs');
const path = require('path');
const db = require('../config/db');

const DATA_DIR = process.env.DB_DATA_DIR || '/data/wifiplatform/mysql';
const DB_NAME = process.env.DB_NAME || 'wifiplatform';
const DB_DIR = path.join(DATA_DIR, DB_NAME);
const QUARANTINE_DIR = path.join('/data/wifiplatform/quarantine', 'mysql-table-artifacts');
const REPAIRABLE_TABLES = [
  'access_tokens',
  'payments',
  'device_portal_settings',
  'device_portal_payment_options',
  'token_compensations',
  'token_deauth_events',
  'client_notification_preferences',
];

const migrations = [
  // 001 β€” SSID / WLAN fields on devices
  `ALTER TABLE devices ADD COLUMN ssid_name     VARCHAR(64) DEFAULT NULL AFTER device_password`,
  `ALTER TABLE devices ADD COLUMN ssid_password VARCHAR(64) DEFAULT NULL AFTER ssid_name`,
  `ALTER TABLE devices ADD COLUMN omada_wlan_id VARCHAR(64) DEFAULT NULL AFTER omada_portal_id`,
  `ALTER TABLE devices ADD COLUMN omada_ssid_id VARCHAR(64) DEFAULT NULL AFTER omada_wlan_id`,

  // 002 β€” Portal customisation settings per device
  `CREATE TABLE IF NOT EXISTS device_portal_settings (
    device_id     INT UNSIGNED NOT NULL PRIMARY KEY,
    display_name  VARCHAR(128) DEFAULT NULL,
    welcome_text  VARCHAR(255) DEFAULT NULL,
    logo_url      VARCHAR(512) DEFAULT NULL,
    support_phone VARCHAR(20)  DEFAULT NULL,
    primary_color VARCHAR(7)   DEFAULT NULL,
    bg_color      VARCHAR(7)   DEFAULT NULL,
    FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

  // 003 β€” Ensure nullable columns on device_portal_settings (fix pre-migration tables)
  `ALTER TABLE device_portal_settings MODIFY COLUMN bg_color VARCHAR(7) DEFAULT NULL`,
  `ALTER TABLE device_portal_settings MODIFY COLUMN primary_color VARCHAR(7) DEFAULT NULL`,
  `ALTER TABLE device_portal_settings ADD COLUMN manual_sales_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER bg_color`,
  `ALTER TABLE device_portal_settings ADD COLUMN portal_payment_mode VARCHAR(24) NOT NULL DEFAULT 'both' AFTER manual_sales_enabled`,

  // 003b β€” Uploaded per-device portal payment options
  `CREATE TABLE IF NOT EXISTS device_portal_payment_options (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    device_id INT UNSIGNED NOT NULL,
    provider_name VARCHAR(100) NOT NULL,
    image_path VARCHAR(255) NOT NULL,
    display_order INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    KEY idx_portal_payment_device (device_id),
    CONSTRAINT fk_portal_payment_options_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

  // 004 β€” Fix any invalid access_tokens.status values before modifying the ENUM
  `UPDATE access_tokens SET status = 'active' WHERE status NOT IN ('unused','active','expired','revoked')`,
  `ALTER TABLE access_tokens MODIFY COLUMN status ENUM('unused','active','expired','revoked') NOT NULL DEFAULT 'unused'`,

  // 005 β€” Fix any invalid payments.status values before modifying the ENUM
  `UPDATE payments SET status = 'pending' WHERE status NOT IN ('pending','completed','failed','refunded','voided')`,
  `ALTER TABLE payments MODIFY COLUMN status ENUM('pending','completed','failed','refunded','voided') NOT NULL DEFAULT 'pending'`,

  // 006 β€” Audit trail for tenant/admin customer compensation extensions
  `CREATE TABLE IF NOT EXISTS token_compensations (
    id                BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    token_id          BIGINT UNSIGNED NOT NULL,
    payment_id        BIGINT UNSIGNED NOT NULL,
    client_id         INT UNSIGNED NOT NULL,
    device_id         INT UNSIGNED NOT NULL,
    actor_type        ENUM('tenant','admin') NOT NULL,
    actor_client_id   INT UNSIGNED DEFAULT NULL,
    granted_seconds   INT UNSIGNED NOT NULL,
    old_expires_at    TIMESTAMP NULL DEFAULT NULL,
    new_expires_at    TIMESTAMP NOT NULL,
    reason            VARCHAR(255) DEFAULT NULL,
    created_at        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    KEY idx_token_comp_token (token_id),
    KEY idx_token_comp_client (client_id),
    KEY idx_token_comp_payment (payment_id),
    CONSTRAINT fk_token_comp_token FOREIGN KEY (token_id) REFERENCES access_tokens(id) ON DELETE CASCADE,
    CONSTRAINT fk_token_comp_payment FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE,
    CONSTRAINT fk_token_comp_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
    CONSTRAINT fk_token_comp_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

  // 007 β€” Tenant SMS notification preferences
  `CREATE TABLE IF NOT EXISTS client_notification_preferences (
    client_id          INT UNSIGNED NOT NULL PRIMARY KEY,
    device_renewal     TINYINT(1) NOT NULL DEFAULT 1,
    device_suspended   TINYINT(1) NOT NULL DEFAULT 1,
    device_paused      TINYINT(1) NOT NULL DEFAULT 1,
    billing_reminder   TINYINT(1) NOT NULL DEFAULT 1,
    payout_completed   TINYINT(1) NOT NULL DEFAULT 1,
    payout_failed      TINYINT(1) NOT NULL DEFAULT 1,
    trial_granted      TINYINT(1) NOT NULL DEFAULT 1,
    purchase_alerts    TINYINT(1) NOT NULL DEFAULT 1,
    system_alerts      TINYINT(1) NOT NULL DEFAULT 1,
    updated_at         TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    CONSTRAINT fk_client_notification_preferences_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

  // 008 β€” Add in-app alert notification preferences to existing installs
  `ALTER TABLE client_notification_preferences ADD COLUMN purchase_alerts TINYINT(1) NOT NULL DEFAULT 1 AFTER trial_granted`,
  `ALTER TABLE client_notification_preferences ADD COLUMN system_alerts TINYINT(1) NOT NULL DEFAULT 1 AFTER purchase_alerts`,

  // 009 β€” Tenant direct-payment settings on clients
  `ALTER TABLE clients ADD COLUMN payment_display_name VARCHAR(100) DEFAULT NULL AFTER phone`,
  `ALTER TABLE clients ADD COLUMN payment_phone VARCHAR(20) DEFAULT NULL AFTER payment_display_name`,
  `ALTER TABLE clients ADD COLUMN payment_provider VARCHAR(32) DEFAULT NULL AFTER payment_phone`,
  `ALTER TABLE clients ADD COLUMN payment_instructions VARCHAR(255) DEFAULT NULL AFTER payment_provider`,
  `ALTER TABLE clients ADD COLUMN payment_qr_image_url VARCHAR(512) DEFAULT NULL AFTER payment_instructions`,
  `ALTER TABLE clients ADD COLUMN manual_sales_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER payment_qr_image_url`,
  `ALTER TABLE clients ADD COLUMN portal_payment_mode VARCHAR(24) NOT NULL DEFAULT 'both' AFTER manual_sales_enabled`,

  // 010 β€” Manual/direct payment tracking fields on payments
  `ALTER TABLE payments MODIFY COLUMN phone VARCHAR(20) NULL`,
  `ALTER TABLE payments MODIFY COLUMN phone_provider ENUM('mpesa','selcom','airtel','tigo','halopesa','bank','other') NOT NULL DEFAULT 'mpesa'`,
  `ALTER TABLE payments ADD COLUMN payment_channel ENUM('snippe','tenant_direct_manual') NOT NULL DEFAULT 'snippe' AFTER phone_provider`,
  `ALTER TABLE payments ADD COLUMN verification_source ENUM('webhook','tenant_confirmed') DEFAULT NULL AFTER payment_channel`,
  `ALTER TABLE payments ADD COLUMN counts_toward_balance TINYINT(1) NOT NULL DEFAULT 1 AFTER verification_source`,
  `ALTER TABLE payments ADD COLUMN confirmed_by_client_id INT UNSIGNED DEFAULT NULL AFTER counts_toward_balance`,
  `ALTER TABLE payments ADD COLUMN confirmed_at TIMESTAMP NULL DEFAULT NULL AFTER confirmed_by_client_id`,
  `ALTER TABLE payments ADD COLUMN notes VARCHAR(255) DEFAULT NULL AFTER confirmed_at`,

  // 011 - Track successful Omada deauthorization without altering access_tokens
  `CREATE TABLE IF NOT EXISTS token_deauth_events (
    token_id      BIGINT UNSIGNED NOT NULL PRIMARY KEY,
    client_id     INT UNSIGNED NOT NULL,
    device_id     INT UNSIGNED NOT NULL,
    omada_site_id VARCHAR(64) NOT NULL,
    locked_mac    VARCHAR(17) NOT NULL,
    status        ENUM('succeeded') NOT NULL DEFAULT 'succeeded',
    succeeded_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    KEY idx_token_deauth_mac (device_id, locked_mac),
    KEY idx_token_deauth_site (omada_site_id),
    CONSTRAINT fk_token_deauth_token FOREIGN KEY (token_id) REFERENCES access_tokens(id) ON DELETE CASCADE,
    CONSTRAINT fk_token_deauth_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
    CONSTRAINT fk_token_deauth_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

];

function summarizeSql(sql) {
  return sql.replace(/\s+/g, ' ').trim().slice(0, 160);
}

function extractTargetTable(sql) {
  return (
    sql.match(/ALTER TABLE\s+`?([a-zA-Z0-9_]+)`?/i)?.[1] ||
    sql.match(/CREATE TABLE(?: IF NOT EXISTS)?\s+`?([a-zA-Z0-9_]+)`?/i)?.[1] ||
    null
  );
}

function extractAddColumnName(sql) {
  return sql.match(/ADD COLUMN\s+`?([a-zA-Z0-9_]+)`?/i)?.[1] || null;
}

function extractModifyColumnName(sql) {
  return sql.match(/MODIFY COLUMN\s+`?([a-zA-Z0-9_]+)`?/i)?.[1] || null;
}

async function getColumnMetadata(table, column) {
  const rows = await db.query(
    `SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_TYPE
     FROM information_schema.COLUMNS
     WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
     LIMIT 1`,
    [DB_NAME, table, column]
  );
  return rows[0] || null;
}

async function getCreateTableSql(table) {
  const rows = await db.query(`SHOW CREATE TABLE \`${table}\``);
  return rows[0]?.['Create Table'] || null;
}

async function isMigrationAlreadySatisfied(sql, table) {
  const addColumn = extractAddColumnName(sql);
  if (addColumn) {
    const column = await getColumnMetadata(table, addColumn);
    return Boolean(column);
  }

  const modifyColumn = extractModifyColumnName(sql);
  if (modifyColumn) {
    const column = await getColumnMetadata(table, modifyColumn);
    if (!column) return false;

    if (/DEFAULT NULL/i.test(sql) && column.IS_NULLABLE === 'YES') {
      return true;
    }

    const enumMatch = sql.match(/MODIFY COLUMN\s+`?[a-zA-Z0-9_]+`?\s+ENUM\(([^)]+)\)\s+NOT NULL DEFAULT\s+'([^']+)'/i);
    if (enumMatch) {
      const expectedType = `enum(${enumMatch[1]})`.toLowerCase().replace(/\s+/g, '');
      const actualType = String(column.COLUMN_TYPE || '').toLowerCase().replace(/\s+/g, '');
      const expectedDefault = enumMatch[2];
      if (actualType === expectedType && String(column.COLUMN_DEFAULT || '') === expectedDefault) {
        return true;
      }

      const createSql = await getCreateTableSql(table);
      if (createSql) {
        const normalizedCreateSql = createSql.toLowerCase().replace(/\s+/g, ' ');
        const normalizedColumnFragment =
          `\`${modifyColumn}\` ${`enum(${enumMatch[1]})`.toLowerCase()} not null default '${expectedDefault.toLowerCase()}'`;
        if (normalizedCreateSql.includes(normalizedColumnFragment)) {
          return true;
        }
      }
    }

    const varcharNullMatch = sql.match(/MODIFY COLUMN\s+`?[a-zA-Z0-9_]+`?\s+VARCHAR\((\d+)\)\s+NULL/i);
    if (varcharNullMatch) {
      return column.IS_NULLABLE === 'YES';
    }
  }

  return false;
}

async function logMariaDiagnostics(table, sql, err) {
  console.error('[migrate] Diagnostic SQL:', summarizeSql(sql));
  console.error('[migrate] Diagnostic error meta:', JSON.stringify({
    errno: err?.errno ?? null,
    code: err?.code ?? null,
    sqlState: err?.sqlState ?? null,
    message: err?.message ?? null,
    dbDir: DB_DIR,
    dbDirExists: fs.existsSync(DB_DIR),
    targetTable: table,
  }));

  if (!table || !fs.existsSync(DB_DIR)) return;

  try {
    const matchingEntries = fs.readdirSync(DB_DIR)
      .filter(entry => entry === table || entry.startsWith(`${table}.`))
      .map(entry => {
        const fullPath = path.join(DB_DIR, entry);
        const stat = fs.statSync(fullPath);
        return {
          name: entry,
          type: stat.isDirectory() ? 'directory' : 'file',
          size: stat.size,
        };
      });

    console.error('[migrate] Matching filesystem entries:', JSON.stringify(matchingEntries));
  } catch (fsErr) {
    console.error('[migrate] Failed to inspect filesystem entries:', fsErr.message);
  }

  try {
    const escapedTable = String(table).replace(/'/g, "''");
    const tableRows = await db.query(`SHOW FULL TABLES LIKE '${escapedTable}'`);
    console.error('[migrate] SHOW FULL TABLES LIKE result:', JSON.stringify(tableRows));
  } catch (showErr) {
    console.error('[migrate] SHOW FULL TABLES LIKE failed:', showErr.message);
  }

  try {
    const createRows = await db.query(`SHOW CREATE TABLE \`${table}\``);
    console.error('[migrate] SHOW CREATE TABLE result:', JSON.stringify(createRows));
  } catch (createErr) {
    console.error('[migrate] SHOW CREATE TABLE failed:', createErr.message);
  }

  const addColumn = extractAddColumnName(sql);
  const modifyColumn = extractModifyColumnName(sql);
  const targetColumn = addColumn || modifyColumn;
  if (!targetColumn) return;

  try {
    const columnRows = await db.query(
      `SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_TYPE
       FROM information_schema.COLUMNS
       WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
       LIMIT 1`,
      [DB_NAME, table, targetColumn]
    );
    console.error('[migrate] information_schema column result:', JSON.stringify(columnRows));
  } catch (columnErr) {
    console.error('[migrate] information_schema column lookup failed:', columnErr.message);
  }
}

function repairBlockingMariaArtifacts() {
  if (!fs.existsSync(DB_DIR)) return false;

  const quarantineRoot = path.join(QUARANTINE_DIR, String(Date.now()));
  let repaired = false;

  for (const table of REPAIRABLE_TABLES) {
    const candidates = [path.join(DB_DIR, table)];

    for (const entry of fs.readdirSync(DB_DIR)) {
      if (entry === table || entry.startsWith(`${table}.`)) {
        candidates.push(path.join(DB_DIR, entry));
      }
    }

    for (const candidate of new Set(candidates)) {
      if (!fs.existsSync(candidate)) continue;

      let stat;
      try {
        stat = fs.statSync(candidate);
      } catch {
        continue;
      }

      if (!stat.isDirectory()) continue;

      fs.mkdirSync(quarantineRoot, { recursive: true });
      const destination = path.join(quarantineRoot, path.basename(candidate));
      fs.renameSync(candidate, destination);
      console.warn(`[migrate] Quarantined blocking MariaDB artifact: ${candidate}`);
      repaired = true;
    }
  }

  return repaired;
}

async function runMigrations() {
  console.log('[migrate] Running schema migrations...');
  for (const sql of migrations) {
    const targetTable = extractTargetTable(sql);
    try {
      await db.query(sql);
      // Extract column name from the SQL for a readable log line
      const col = sql.match(/(?:ADD|MODIFY) COLUMN (\w+)/i)?.[1] ?? sql.slice(0, 60);
      console.log(`[migrate] Applied: ${col}`);
    } catch (err) {
      if (targetTable && REPAIRABLE_TABLES.includes(targetTable)) {
        await logMariaDiagnostics(targetTable, sql, err);
      }
      if (err.errno === 1060 || err.errno === 1091) {
        // Column already exists β€” nothing to do
      } else if ((err.errno === 1005 || err.errno === 21) && targetTable && await isMigrationAlreadySatisfied(sql, targetTable)) {
        const col = extractAddColumnName(sql) || extractModifyColumnName(sql) || targetTable;
        console.warn(`[migrate] Skipping already-satisfied migration after MariaDB alter failure: ${col}`);
      } else if (err.errno === 21 && repairBlockingMariaArtifacts()) {
        console.warn(`[migrate] Retrying after artifact repair for table ${targetTable}`);
        await db.query(sql);
        const col = sql.match(/(?:ADD|MODIFY) COLUMN (\w+)/i)?.[1] ?? sql.slice(0, 60);
        console.log(`[migrate] Applied after repair: ${col}`);
      } else {
        console.error('[migrate] Migration failed:', err.message);
        throw err;
      }
    }
  }
  console.log('[migrate] Done.');
}

module.exports = { runMigrations };