/ db / migrations / migrate-messages-to-shared.js
migrate-messages-to-shared.js
  1  #!/usr/bin/env node
  2  /**
  3   * migrate-messages-to-shared.js
  4   *
  5   * One-shot migration: copies 333Method's messages, opt_outs, and
  6   * unsubscribed_emails from sites.db into mmo-platform/db/messages.db.
  7   *
  8   * Safe to re-run — all INSERTs use INSERT OR IGNORE.
  9   *
 10   * Column mapping notes (333Method → shared):
 11   *   - project              ← hardcoded '333method'
 12   *   - approval_status      ← 'gdpr_blocked' mapped to 'parked'
 13   *                             (gdpr_blocked is not in shared CHECK constraint)
 14   *   - message_type         ← 'reply_skipped' mapped to 'reply'
 15   *                             (reply_skipped is not in shared CHECK constraint)
 16   *   - video_url            ← NULL (333method has no videos)
 17   *   - pricing_id           ← NULL (333method uses pricing_variant free-text)
 18   *   - price_discount_pct   ← NULL
 19   *
 20   * Columns present in 333Method but not in shared schema (dropped silently):
 21   *   tracking_clicked_at, read_at, payment_id, payment_amount_local,
 22   *   payment_amount_usd, exchange_rate, pricing_variant, report_url,
 23   *   our_account, zb_status, retry_count, reworded_at
 24   *
 25   * Usage:
 26   *   node db/migrations/migrate-messages-to-shared.js [--dry-run]
 27   */
 28  
 29  import Database from 'better-sqlite3';
 30  import { existsSync } from 'fs';
 31  import path from 'path';
 32  import { fileURLToPath } from 'url';
 33  
 34  const __dirname = path.dirname(fileURLToPath(import.meta.url));
 35  
 36  const DRY_RUN = process.argv.includes('--dry-run');
 37  
 38  // ---------------------------------------------------------------------------
 39  // Paths
 40  // ---------------------------------------------------------------------------
 41  
 42  const sitesDbPath = process.env.DATABASE_PATH
 43    || path.resolve(__dirname, '../../db/sites.db');
 44  
 45  const messagesDbPath = process.env.MESSAGES_DB_PATH
 46    || path.resolve(__dirname, '../../../mmo-platform/db/messages.db');
 47  
 48  // ---------------------------------------------------------------------------
 49  // Guard: both files must exist before we start
 50  // ---------------------------------------------------------------------------
 51  
 52  if (!existsSync(sitesDbPath)) {
 53    console.error(`ERROR: sites.db not found at: ${sitesDbPath}`);
 54    process.exit(1);
 55  }
 56  
 57  if (!existsSync(messagesDbPath)) {
 58    console.error(`ERROR: messages.db not found at: ${messagesDbPath}`);
 59    console.error('Run mmo-platform/scripts/init-messages-db.js first.');
 60    process.exit(1);
 61  }
 62  
 63  // ---------------------------------------------------------------------------
 64  // Open databases
 65  // ---------------------------------------------------------------------------
 66  
 67  const src = new Database(sitesDbPath, { readonly: true });
 68  const dst = new Database(messagesDbPath);
 69  
 70  dst.pragma('journal_mode = WAL');
 71  dst.pragma('busy_timeout = 30000');
 72  
 73  // ATTACH source so we can query both in a single connection for counts
 74  dst.exec(`ATTACH DATABASE '${sitesDbPath}' AS src`);
 75  
 76  // ---------------------------------------------------------------------------
 77  // Pre-migration counts
 78  // ---------------------------------------------------------------------------
 79  
 80  const srcMessageCount       = src.prepare('SELECT COUNT(*) AS n FROM messages').get().n;
 81  const srcOptOutCount        = src.prepare('SELECT COUNT(*) AS n FROM opt_outs').get().n;
 82  const srcUnsubCount         = src.prepare('SELECT COUNT(*) AS n FROM unsubscribed_emails').get().n;
 83  
 84  const dstMessagesBefore     = dst.prepare("SELECT COUNT(*) AS n FROM messages WHERE project='333method'").get().n;
 85  const dstOptOutsBefore      = dst.prepare('SELECT COUNT(*) AS n FROM opt_outs').get().n;
 86  const dstUnsubBefore        = dst.prepare('SELECT COUNT(*) AS n FROM unsubscribed_emails').get().n;
 87  
 88  console.log('=== 333Method → messages.db migration ===');
 89  console.log(`Source:      ${sitesDbPath}`);
 90  console.log(`Destination: ${messagesDbPath}`);
 91  if (DRY_RUN) console.log('DRY RUN — no writes will be committed');
 92  console.log('');
 93  console.log('Pre-migration counts:');
 94  console.log(`  sites.db   messages:             ${srcMessageCount.toLocaleString()}`);
 95  console.log(`  sites.db   opt_outs:             ${srcOptOutCount.toLocaleString()}`);
 96  console.log(`  sites.db   unsubscribed_emails:  ${srcUnsubCount.toLocaleString()}`);
 97  console.log(`  messages.db messages (333method): ${dstMessagesBefore.toLocaleString()}`);
 98  console.log(`  messages.db opt_outs:            ${dstOptOutsBefore.toLocaleString()}`);
 99  console.log(`  messages.db unsubscribed_emails: ${dstUnsubBefore.toLocaleString()}`);
100  console.log('');
101  
102  // ---------------------------------------------------------------------------
103  // Migration helpers
104  // ---------------------------------------------------------------------------
105  
106  /**
107   * Map approval_status values that exist in sites.db but not in the shared
108   * schema's CHECK constraint.
109   *   gdpr_blocked → parked  (compliance-parked, semantically equivalent)
110   */
111  function mapApprovalStatus(v) {
112    if (v === 'gdpr_blocked') return 'parked';
113    return v;
114  }
115  
116  /**
117   * Map message_type values that exist in sites.db but not in the shared
118   * schema's CHECK constraint.
119   *   reply_skipped → reply  (it is a reply-type message, just skipped for send)
120   */
121  function mapMessageType(v) {
122    if (v === 'reply_skipped') return 'reply';
123    return v || 'outreach';
124  }
125  
126  // ---------------------------------------------------------------------------
127  // Build the INSERT statement
128  // Columns in shared messages that we can populate from 333Method:
129  //   id, project, site_id, direction, contact_method, contact_uri,
130  //   message_body, subject_line, approval_status, rework_instructions,
131  //   exported_at, delivery_status, error_message, retry_at,
132  //   sent_at, delivered_at, email_id, opened_at, sentiment, intent,
133  //   message_type, raw_payload, is_read, processed_at,
134  //   payment_link, payment_amount, payment_currency, template_id,
135  //   created_at, updated_at
136  //
137  // Populated as NULL / default:
138  //   video_url, pricing_id, price_discount_pct
139  // ---------------------------------------------------------------------------
140  
141  const insertMessage = dst.prepare(`
142    INSERT OR IGNORE INTO messages (
143      id,
144      project,
145      site_id,
146      direction,
147      contact_method,
148      contact_uri,
149      message_body,
150      subject_line,
151      video_url,
152      approval_status,
153      rework_instructions,
154      exported_at,
155      delivery_status,
156      error_message,
157      retry_at,
158      sent_at,
159      delivered_at,
160      email_id,
161      opened_at,
162      sentiment,
163      intent,
164      message_type,
165      raw_payload,
166      is_read,
167      processed_at,
168      payment_link,
169      payment_amount,
170      payment_currency,
171      pricing_id,
172      price_discount_pct,
173      template_id,
174      created_at,
175      updated_at
176    ) VALUES (
177      @id,
178      '333method',
179      @site_id,
180      @direction,
181      @contact_method,
182      @contact_uri,
183      @message_body,
184      @subject_line,
185      NULL,
186      @approval_status,
187      @rework_instructions,
188      @exported_at,
189      @delivery_status,
190      @error_message,
191      @retry_at,
192      @sent_at,
193      @delivered_at,
194      @email_id,
195      @opened_at,
196      @sentiment,
197      @intent,
198      @message_type,
199      @raw_payload,
200      @is_read,
201      @processed_at,
202      @payment_link,
203      @payment_amount,
204      @payment_currency,
205      NULL,
206      NULL,
207      @template_id,
208      @created_at,
209      @updated_at
210    )
211  `);
212  
213  const insertOptOut = dst.prepare(`
214    INSERT OR IGNORE INTO opt_outs (
215      id, phone, email, method, project, opted_out_at, source, created_at
216    ) VALUES (
217      @id, @phone, @email, @method, '333method', @opted_out_at, @source, @created_at
218    )
219  `);
220  
221  const insertUnsub = dst.prepare(`
222    INSERT OR IGNORE INTO unsubscribed_emails (
223      id, email, message_id, project, unsubscribed_at, source, created_at
224    ) VALUES (
225      @id, @email, NULL, '333method', @unsubscribed_at, @source, @created_at
226    )
227  `);
228  
229  // ---------------------------------------------------------------------------
230  // Execute migration in a single transaction
231  // ---------------------------------------------------------------------------
232  
233  const migrate = dst.transaction(() => {
234    // --- messages ---
235    const allMessages = src.prepare(`
236      SELECT
237        id, site_id, direction, contact_method, contact_uri,
238        message_body, subject_line, approval_status, rework_instructions,
239        exported_at, delivery_status, error_message, retry_at,
240        sent_at, delivered_at, email_id, opened_at, sentiment, intent,
241        raw_payload, is_read, processed_at, payment_link, payment_amount,
242        payment_currency, template_id, created_at, updated_at, message_type
243      FROM messages
244      ORDER BY id
245    `).all();
246  
247    let msgInserted = 0;
248    let msgSkipped  = 0;
249  
250    for (const row of allMessages) {
251      const result = insertMessage.run({
252        ...row,
253        approval_status: mapApprovalStatus(row.approval_status),
254        message_type:    mapMessageType(row.message_type),
255      });
256      if (result.changes > 0) {
257        msgInserted++;
258      } else {
259        msgSkipped++;
260      }
261    }
262  
263    // --- opt_outs ---
264    const allOptOuts = src.prepare('SELECT * FROM opt_outs').all();
265    let optOutInserted = 0;
266    let optOutSkipped  = 0;
267  
268    for (const row of allOptOuts) {
269      const result = insertOptOut.run(row);
270      if (result.changes > 0) {
271        optOutInserted++;
272      } else {
273        optOutSkipped++;
274      }
275    }
276  
277    // --- unsubscribed_emails ---
278    const allUnsubs = src.prepare('SELECT * FROM unsubscribed_emails').all();
279    let unsubInserted = 0;
280    let unsubSkipped  = 0;
281  
282    for (const row of allUnsubs) {
283      const result = insertUnsub.run(row);
284      if (result.changes > 0) {
285        unsubInserted++;
286      } else {
287        unsubSkipped++;
288      }
289    }
290  
291    return { msgInserted, msgSkipped, optOutInserted, optOutSkipped, unsubInserted, unsubSkipped };
292  });
293  
294  let stats;
295  if (DRY_RUN) {
296    // Run inside a transaction that we immediately roll back
297    const dryRunTx = dst.transaction(() => {
298      stats = migrate();
299      // Force rollback by throwing after we have the stats
300      throw new Error('DRY_RUN_ROLLBACK');
301    });
302    try {
303      dryRunTx();
304    } catch (e) {
305      if (e.message !== 'DRY_RUN_ROLLBACK') throw e;
306    }
307  } else {
308    stats = migrate();
309  }
310  
311  // ---------------------------------------------------------------------------
312  // Post-migration counts
313  // ---------------------------------------------------------------------------
314  
315  const dstMessagesAfter = dst.prepare("SELECT COUNT(*) AS n FROM messages WHERE project='333method'").get().n;
316  const dstOptOutsAfter  = dst.prepare('SELECT COUNT(*) AS n FROM opt_outs').get().n;
317  const dstUnsubAfter    = dst.prepare('SELECT COUNT(*) AS n FROM unsubscribed_emails').get().n;
318  
319  console.log(`Migration results${DRY_RUN ? ' (DRY RUN — rolled back)' : ''}:`);
320  console.log(`  messages:            ${stats.msgInserted.toLocaleString()} inserted, ${stats.msgSkipped.toLocaleString()} skipped (already existed)`);
321  console.log(`  opt_outs:            ${stats.optOutInserted.toLocaleString()} inserted, ${stats.optOutSkipped.toLocaleString()} skipped`);
322  console.log(`  unsubscribed_emails: ${stats.unsubInserted.toLocaleString()} inserted, ${stats.unsubSkipped.toLocaleString()} skipped`);
323  console.log('');
324  console.log('Post-migration counts in messages.db:');
325  console.log(`  messages (333method): ${dstMessagesAfter.toLocaleString()} (was ${dstMessagesBefore.toLocaleString()})`);
326  console.log(`  opt_outs:            ${dstOptOutsAfter.toLocaleString()} (was ${dstOptOutsBefore.toLocaleString()})`);
327  console.log(`  unsubscribed_emails: ${dstUnsubAfter.toLocaleString()} (was ${dstUnsubBefore.toLocaleString()})`);
328  console.log('');
329  
330  // Sanity check
331  if (!DRY_RUN) {
332    const expectedMessages = dstMessagesBefore + stats.msgInserted;
333    if (dstMessagesAfter !== expectedMessages) {
334      console.error(`WARNING: count mismatch — expected ${expectedMessages}, got ${dstMessagesAfter}`);
335      process.exit(1);
336    }
337    console.log('Sanity check: PASSED');
338    console.log('Migration complete.');
339  } else {
340    console.log('Dry run complete — no changes committed.');
341  }
342  
343  src.close();
344  dst.close();