adopt-messaging-integrations.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. /**
  2. * Moves every existing messaging setup into a connection, in one pass.
  3. *
  4. * The app adopts a setup on its own the first time a workshop sends on a
  5. * channel, so this script is not required for correctness — it just means the
  6. * catalog shows Twilio, WhatsApp, Telegram and mail as connected from the
  7. * moment the deploy lands, rather than the first time each workshop sends
  8. * something. Nothing is deleted: the old `AppSetting` rows stay exactly where
  9. * they are, so this can be run before a rollback is ruled out.
  10. *
  11. * Running it twice is safe. A workshop that already has a connection for a
  12. * channel, or that has already been adopted, is left alone.
  13. *
  14. * It must run with the same INTEGRATIONS_ENCRYPTION_KEY the app uses: a
  15. * connection sealed under any other key is unreadable by the app, and the
  16. * adoption marker then stops the app from adopting the rows again. The script
  17. * therefore refuses to run on a derived key and prints a fingerprint of the
  18. * key it holds, to compare with the app container's.
  19. *
  20. * docker run --rm --network proxy --env-file /path/to/prod/.env \
  21. * -v $(pwd):/src -w /src node:22-alpine sh -c \
  22. * 'npm ci --ignore-scripts && npx prisma generate && \
  23. * npx tsx scripts/adopt-messaging-integrations.ts' # report only
  24. * ... npx tsx scripts/adopt-messaging-integrations.ts --write # adopt
  25. */
  26. import { createHash } from 'node:crypto'
  27. import { adoptedMarkerKey, channelSetup, legacySetupFor } from '@/features/integrations/Lib/messaging'
  28. import type { MessagingChannel } from '@/integrations/messaging/catalog'
  29. import { legacyKeysForChannel, providersForChannel } from '@/integrations/messaging/catalog'
  30. import { db } from '@/lib/db'
  31. const CHANNELS: MessagingChannel[] = ['sms', 'whatsapp', 'telegram', 'email']
  32. function requireVaultKey(): void {
  33. const key = process.env.INTEGRATIONS_ENCRYPTION_KEY?.trim()
  34. if (!key) {
  35. console.error(
  36. 'INTEGRATIONS_ENCRYPTION_KEY is not set. Run this with the app\'s own environment ' +
  37. '(for example --env-file with the production .env) so connections are sealed with ' +
  38. 'the key the app reads them with. Refusing to continue.'
  39. )
  40. process.exit(2)
  41. }
  42. const fingerprint = createHash('sha256').update(key).digest('hex').slice(0, 8)
  43. console.log(
  44. `Sealing with INTEGRATIONS_ENCRYPTION_KEY fingerprint ${fingerprint}. ` +
  45. 'Compare: docker exec torqvoice-app sh -c \'printf %s "$INTEGRATIONS_ENCRYPTION_KEY" | sha256sum | cut -c1-8\''
  46. )
  47. }
  48. async function organizationsWithLegacySetup(channel: MessagingChannel): Promise<string[]> {
  49. const rows = await db.appSetting.findMany({
  50. where: { key: { in: legacyKeysForChannel(channel) }, NOT: { value: '' } },
  51. select: { organizationId: true },
  52. distinct: ['organizationId'],
  53. })
  54. return rows.map((r) => r.organizationId).filter((id): id is string => Boolean(id))
  55. }
  56. async function main(): Promise<void> {
  57. const write = process.argv.includes('--write')
  58. requireVaultKey()
  59. let adopted = 0
  60. let alreadyConnected = 0
  61. let alreadyAdopted = 0
  62. let incomplete = 0
  63. let failed = 0
  64. for (const channel of CHANNELS) {
  65. const connectorIds = providersForChannel(channel).map((p) => p.id)
  66. const organizationIds = await organizationsWithLegacySetup(channel)
  67. for (const organizationId of organizationIds) {
  68. const [existing, marker] = await Promise.all([
  69. db.integrationConnection.findFirst({
  70. where: { organizationId, connectorId: { in: connectorIds } },
  71. select: { connectorId: true, status: true },
  72. }),
  73. db.appSetting.findUnique({
  74. where: { organizationId_key: { organizationId, key: adoptedMarkerKey(channel) } },
  75. select: { value: true },
  76. }),
  77. ])
  78. if (existing) {
  79. alreadyConnected++
  80. continue
  81. }
  82. if (marker) {
  83. // Adopted and later disconnected by the workshop: that is its choice.
  84. alreadyAdopted++
  85. continue
  86. }
  87. // The same read the app does, without writing, so the report says
  88. // exactly what --write would do.
  89. const { setup } = await legacySetupFor(organizationId, channel)
  90. if (!setup) {
  91. console.log(
  92. `incomplete ${channel} for ${organizationId}: no vendor named, or its keys are missing`
  93. )
  94. incomplete++
  95. continue
  96. }
  97. if (!write) {
  98. console.log(`would adopt ${channel} -> ${setup.provider.id} for ${organizationId}`)
  99. adopted++
  100. continue
  101. }
  102. try {
  103. const live = await channelSetup(organizationId, channel)
  104. if (live) {
  105. console.log(`adopted ${channel} -> ${live.connectorId} for ${organizationId}`)
  106. adopted++
  107. } else {
  108. console.log(`skipped ${channel} for ${organizationId}: adoption returned nothing`)
  109. incomplete++
  110. }
  111. } catch (err) {
  112. console.error(`failed ${channel} for ${organizationId}:`, err)
  113. failed++
  114. }
  115. }
  116. }
  117. console.log(
  118. `\n${write ? 'Adopted' : 'Would adopt'} ${adopted}, already connected ${alreadyConnected}, ` +
  119. `previously adopted ${alreadyAdopted}, incomplete ${incomplete}, failed ${failed}.`
  120. )
  121. if (!write) console.log('Run again with --write to make the changes.')
  122. if (failed > 0) process.exitCode = 1
  123. }
  124. main()
  125. .catch((err) => {
  126. console.error(err)
  127. process.exitCode = 1
  128. })
  129. .finally(() => db.$disconnect())