@fayz-ai/plugin-conversations 0.23.14 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AtendeAIPage-PBBMWIDW.js +92 -0
- package/dist/AtendeAIPage-PBBMWIDW.js.map +1 -0
- package/dist/ConversationsPage-BTRYZPM7.js +1274 -0
- package/dist/ConversationsPage-BTRYZPM7.js.map +1 -0
- package/dist/ConversationsSettings-Q3PGQMB3.js +158 -0
- package/dist/ConversationsSettings-Q3PGQMB3.js.map +1 -0
- package/dist/chunk-2JV6B366.js +319 -0
- package/dist/chunk-2JV6B366.js.map +1 -0
- package/dist/chunk-HMWUU5O7.js +75 -0
- package/dist/chunk-HMWUU5O7.js.map +1 -0
- package/dist/chunk-X7BP2JH6.js +74 -0
- package/dist/chunk-X7BP2JH6.js.map +1 -0
- package/dist/components/InboxWatcher.d.ts +6 -15
- package/dist/components/InboxWatcher.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +161 -4206
- package/dist/index.js.map +1 -1
- package/dist/integrations/evolution/connectorDef.d.ts +4 -0
- package/dist/integrations/evolution/connectorDef.d.ts.map +1 -0
- package/dist/migrations/main.browser.js +23 -0
- package/dist/migrations/main.browser.js.map +1 -0
- package/dist/migrations/main.js +2141 -0
- package/dist/migrations/main.js.map +1 -0
- package/dist/views/ConversationList.d.ts.map +1 -1
- package/dist/views/ConversationsPage.d.ts.map +1 -1
- package/dist/views/WhatsAppConnectionFooter.d.ts +8 -0
- package/dist/views/WhatsAppConnectionFooter.d.ts.map +1 -0
- package/package.json +10 -4
- package/dist/components/ConversationsRailPanel.d.ts +0 -15
- package/dist/components/ConversationsRailPanel.d.ts.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/migrations/index.ts"],"names":[],"mappings":";AAIO,IAAM,sBAAA,GAAyB,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAogB/B,IAAM,gDAAA,GAAmD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAyGzD,IAAM,oDAAA,GAAuD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAyO7D,IAAM,0DAAA,GAA6D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA0WnE,IAAM,gEAAA,GAAmE,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA6EzE,IAAM,yDAAA,GAA4D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqKlE,IAAM,sCAAA,GAAyC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+H/C,IAAM,6CAAA,GAAgD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA+DtD,IAAM,yCAAA,GAA4C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAgDlD,IAAM,qDAAA,GAAwD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmD9D,IAAM,6CAAA,GAAgD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA4FtD,IAAM,qDAAA,GAAwD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAgE9D,IAAM,4CAAA,GAA+C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AA+DrD,IAAM,kCAAA,GAAqC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmE3C,IAAM,6CAAA,GAAgD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAatD,IAAM,4CAAA,GAA+C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwDrD,IAAM,iDAAA,GAAoD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgC1D,IAAM,UAAA,GAAiD;AAAA,EAC5D,EAAE,EAAA,EAAI,cAAA,EAAgB,GAAA,EAAK,sBAAA,EAAuB;AAAA,EAClD,EAAE,EAAA,EAAI,wCAAA,EAA0C,GAAA,EAAK,gDAAA,EAAiD;AAAA,EACtG,EAAE,EAAA,EAAI,4CAAA,EAA8C,GAAA,EAAK,oDAAA,EAAqD;AAAA,EAC9G,EAAE,EAAA,EAAI,kDAAA,EAAoD,GAAA,EAAK,0DAAA,EAA2D;AAAA,EAC1H,EAAE,EAAA,EAAI,wDAAA,EAA0D,GAAA,EAAK,gEAAA,EAAiE;AAAA,EACtI,EAAE,EAAA,EAAI,iDAAA,EAAmD,GAAA,EAAK,yDAAA,EAA0D;AAAA,EACxH,EAAE,EAAA,EAAI,8BAAA,EAAgC,GAAA,EAAK,sCAAA,EAAuC;AAAA,EAClF,EAAE,EAAA,EAAI,qCAAA,EAAuC,GAAA,EAAK,6CAAA,EAA8C;AAAA,EAChG,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,6CAAA,EAA+C,GAAA,EAAK,qDAAA,EAAsD;AAAA,EAChH,EAAE,EAAA,EAAI,qCAAA,EAAuC,GAAA,EAAK,6CAAA,EAA8C;AAAA,EAChG,EAAE,EAAA,EAAI,6CAAA,EAA+C,GAAA,EAAK,qDAAA,EAAsD;AAAA,EAChH,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,0BAAA,EAA4B,GAAA,EAAK,kCAAA,EAAmC;AAAA,EAC1E,EAAE,EAAA,EAAI,qCAAA,EAAuC,GAAA,EAAK,6CAAA,EAA8C;AAAA,EAChG,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,yCAAA,EAA2C,GAAA,EAAK,iDAAA;AACxD","file":"main.js","sourcesContent":["// AUTO-GENERATED from 000_baseline.sql, 001_a_caixa_sabe_quem_falou_por_ultimo.sql, 002_a_casa_escolhe_por_onde_o_whatsapp_sai.sql, 003_a_campanha_sai_no_ritmo_que_o_numero_aguenta.sql, 004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever.sql, 005_a_conversa_conta_ao_funil_que_houve_contato.sql, 006_a_caixa_avisa_que_chegou.sql, 007_a_conversa_diz_de_quem_e_o_lead.sql, 008_dar_por_vista_e_uma_decisao.sql, 009_a_mensagem_inteira_atravessa_o_realtime.sql, 010_a_chegada_e_anunciada_por_conta.sql, 011_o_anuncio_e_uma_campainha_nao_um_recado.sql, 012_a_rich_message_keeps_its_shape.sql, 013_a_midia_fica_conosco.sql, 014_claiming_targets_is_server_only.sql, 015_the_service_door_for_messaging.sql, 016_a_delivery_receipt_finds_its_tenant.sql — regenerate with scripts/embed-migrations.mjs\n// SQL files are the source of truth; this inline copy lets the manifest declare\n// migrations as data. Do not edit by hand — run the embed script instead.\n\nexport const MIGRATION_000_BASELINE = `-- ============================================================================\n-- plugins/plugin-conversations/src/migrations/000_baseline.sql — what installing this provisions, as one file.\n--\n-- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:\n-- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs\n-- prove the result still matches.\n--\n-- This replaced 9 migration files. They are kept, unapplied, in\n-- migrations.archive/ — a year of decisions is worth reading even when it is no\n-- longer worth replaying.\n--\n-- Object order is fixed rather than dependency-sorted: schemas, types,\n-- sequences, tables, defaults, functions, constraints, views, indexes, foreign\n-- keys, triggers, row security, policies, grants. Dependencies BETWEEN units\n-- are carried by the order packages/db/chain.json declares.\n-- ============================================================================\n\n-- A LANGUAGE sql function binds its body at CREATE time, and 570 functions\n-- sorted by name are not sorted by who calls whom. This is what lets them load\n-- in any order; every body is still compiled on first call, so a genuinely\n-- broken reference surfaces then rather than never. Session-scoped.\nSET check_function_bodies = false;\n\n\n\n-- ── default privileges: reset ───────────────────────────────────────────\n-- Cleared BEFORE anything is created, so every object below gets the ACL the\n-- dump describes rather than the image's defaults on top of it. Restored at\n-- the end of this file.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\n\n-- ── table ───────────────────────────────────────────────────────────────\n\nCREATE TABLE public.plg_conversation_messages (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n conversation_id uuid NOT NULL,\n channel text,\n direction text,\n body text NOT NULL,\n author text,\n at timestamp with time zone DEFAULT now(),\n provider_message_id text,\n delivery_status text,\n sender_kind text,\n sender_label text,\n subject_type text,\n subject_id text,\n CONSTRAINT plg_conversation_messages_channel_check CHECK (((channel IS NULL) OR (channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text])))),\n CONSTRAINT plg_conversation_messages_delivery_status_check CHECK (((delivery_status IS NULL) OR (delivery_status = ANY (ARRAY['queued'::text, 'sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'expired'::text, 'delivery_timeout'::text, 'cancelled'::text, 'opted_out'::text])))),\n CONSTRAINT plg_conversation_messages_direction_check CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),\n CONSTRAINT plg_conversation_messages_sender_kind_check CHECK (((sender_kind IS NULL) OR (sender_kind = ANY (ARRAY['user'::text, 'system'::text, 'ai'::text, 'automation'::text]))))\n);\n\nCREATE TABLE public.plg_conversations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n contact_name text NOT NULL,\n contact_handle text,\n channel text NOT NULL,\n last_message_preview text,\n last_message_at timestamp with time zone DEFAULT now(),\n unread_count integer DEFAULT 0,\n status text DEFAULT 'open'::text,\n assigned_to text,\n accent text,\n tags text[],\n location text,\n note text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n contact_person_id uuid,\n CONSTRAINT plg_conversations_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text]))),\n CONSTRAINT plg_conversations_status_check CHECK ((status = ANY (ARRAY['open'::text, 'snoozed'::text, 'closed'::text])))\n);\n\nCREATE TABLE public.plg_conversations_channels (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid,\n channel text DEFAULT 'whatsapp'::text NOT NULL,\n provider text NOT NULL,\n provider_number_id text NOT NULL,\n phone_e164 text,\n kind text DEFAULT 'dedicated'::text NOT NULL,\n status text DEFAULT 'requested'::text NOT NULL,\n display_name text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_conversations_channels_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text]))),\n CONSTRAINT plg_conversations_channels_kind_check CHECK ((kind = ANY (ARRAY['fallback'::text, 'dedicated'::text]))),\n CONSTRAINT plg_conversations_channels_status_check CHECK ((status = ANY (ARRAY['requested'::text, 'provisioning'::text, 'provisioned'::text, 'verifying'::text, 'active'::text, 'failed'::text, 'released'::text, 'disconnected'::text])))\n);\n\nCREATE TABLE public.plg_conversations_optouts (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n channel text DEFAULT 'whatsapp'::text NOT NULL,\n phone_e164 text NOT NULL,\n tenant_id uuid,\n provider text NOT NULL,\n reason text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_conversations_optouts_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text])))\n);\n\nCREATE TABLE public.plg_conversations_payment_requests (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n conversation_id uuid,\n message_id uuid,\n subject_type text,\n subject_id text,\n amount_cents integer NOT NULL,\n currency text DEFAULT 'BRL'::text NOT NULL,\n description text,\n provider text NOT NULL,\n status text DEFAULT 'created'::text NOT NULL,\n provider_payment_id text,\n payment_link_url text,\n pix_copy_paste text,\n link_delivered_at timestamp with time zone,\n settled_notified_at timestamp with time zone,\n settled_at timestamp with time zone,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_conversations_payment_requests_amount_cents_check CHECK ((amount_cents > 0)),\n CONSTRAINT plg_conversations_payment_requests_status_check CHECK ((status = ANY (ARRAY['created'::text, 'link_generated'::text, 'approval_requested'::text, 'approved'::text, 'paid'::text, 'failed'::text, 'expired'::text, 'cancelled'::text])))\n);\n\nCREATE TABLE public.plg_conversations_webhook_events (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n provider text NOT NULL,\n event_id text NOT NULL,\n event_type text NOT NULL,\n received_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\n\n-- ── function ────────────────────────────────────────────────────────────\n\nCREATE FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid DEFAULT NULL::uuid) RETURNS TABLE(id uuid, tenant_id uuid, contact_name text, contact_handle text, contact_person_id uuid)\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\n WITH candidate AS (\n SELECT c.id, c.tenant_id, c.contact_name, c.contact_handle, c.contact_person_id,\n c.last_message_at,\n regexp_replace(COALESCE(c.contact_handle, ''), '[^0-9]', '', 'g') AS digits\n FROM public.plg_conversations c\n WHERE c.channel = p_channel\n AND (p_tenant_id IS NULL OR c.tenant_id = p_tenant_id)\n )\n SELECT id, tenant_id, contact_name, contact_handle, contact_person_id\n FROM candidate\n WHERE length(digits) >= 8\n AND ((p_last10 IS NOT NULL AND right(digits, 10) = p_last10)\n OR (p_last8 IS NOT NULL AND right(digits, 8) = p_last8))\n -- The 10-digit match wins over an 8-digit one even when the 8-digit thread\n -- is newer: a wrong area code is a different person, and a newer wrong\n -- answer is still wrong.\n ORDER BY (p_last10 IS NOT NULL AND right(digits, 10) = p_last10) DESC,\n last_message_at DESC NULLS LAST\n LIMIT 1;\n$$;\n\n\n-- ── constraint ──────────────────────────────────────────────────────────\n\nALTER TABLE ONLY public.plg_conversation_messages\n ADD CONSTRAINT plg_conversation_messages_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_conversations_channels\n ADD CONSTRAINT plg_conversations_channels_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_conversations_optouts\n ADD CONSTRAINT plg_conversations_optouts_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_conversations_payment_requests\n ADD CONSTRAINT plg_conversations_payment_requests_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_conversations\n ADD CONSTRAINT plg_conversations_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_conversations_webhook_events\n ADD CONSTRAINT plg_conversations_webhook_events_pkey PRIMARY KEY (id);\n\n\n-- ── index ───────────────────────────────────────────────────────────────\n\nCREATE INDEX idx_plg_conversation_messages_by_subject ON public.plg_conversation_messages USING btree (tenant_id, subject_type, subject_id) WHERE (subject_id IS NOT NULL);\n\nCREATE INDEX idx_plg_conversation_messages_subject ON public.plg_conversation_messages USING btree (conversation_id, at DESC) WHERE (subject_id IS NOT NULL);\n\nCREATE INDEX idx_plg_conversation_messages_thread ON public.plg_conversation_messages USING btree (conversation_id, at);\n\nCREATE INDEX idx_plg_conversations_channels_tenant ON public.plg_conversations_channels USING btree (tenant_id, channel, status) WHERE (tenant_id IS NOT NULL);\n\nCREATE INDEX idx_plg_conversations_handle_suffix ON public.plg_conversations USING btree (channel, \"right\"(regexp_replace(COALESCE(contact_handle, ''::text), '[^0-9]'::text, ''::text, 'g'::text), 8), last_message_at DESC);\n\nCREATE INDEX idx_plg_conversations_optouts_lookup ON public.plg_conversations_optouts USING btree (channel, phone_e164);\n\nCREATE INDEX idx_plg_conversations_payment_requests_subject ON public.plg_conversations_payment_requests USING btree (tenant_id, subject_type, subject_id) WHERE (subject_id IS NOT NULL);\n\nCREATE INDEX idx_plg_conversations_payment_requests_tenant ON public.plg_conversations_payment_requests USING btree (tenant_id, created_at DESC);\n\nCREATE INDEX idx_plg_conversations_person ON public.plg_conversations USING btree (tenant_id, contact_person_id) WHERE (contact_person_id IS NOT NULL);\n\nCREATE INDEX idx_plg_conversations_tenant ON public.plg_conversations USING btree (tenant_id);\n\nCREATE INDEX idx_plg_conversations_tenant_recent ON public.plg_conversations USING btree (tenant_id, last_message_at DESC);\n\nCREATE INDEX idx_plg_conversations_webhook_events_received ON public.plg_conversations_webhook_events USING btree (received_at);\n\nCREATE UNIQUE INDEX uq_plg_conversation_messages_provider_message ON public.plg_conversation_messages USING btree (provider_message_id) WHERE (provider_message_id IS NOT NULL);\n\nCREATE UNIQUE INDEX uq_plg_conversations_channels_fallback ON public.plg_conversations_channels USING btree (channel, provider) WHERE ((tenant_id IS NULL) AND (kind = 'fallback'::text));\n\nCREATE UNIQUE INDEX uq_plg_conversations_channels_number ON public.plg_conversations_channels USING btree (provider, provider_number_id);\n\nCREATE UNIQUE INDEX uq_plg_conversations_optouts_contact ON public.plg_conversations_optouts USING btree (channel, phone_e164, COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid));\n\nCREATE UNIQUE INDEX uq_plg_conversations_payment_requests_open_subject ON public.plg_conversations_payment_requests USING btree (tenant_id, subject_type, subject_id) WHERE ((subject_id IS NOT NULL) AND (status = ANY (ARRAY['created'::text, 'link_generated'::text, 'approval_requested'::text, 'approved'::text])));\n\nCREATE UNIQUE INDEX uq_plg_conversations_payment_requests_provider ON public.plg_conversations_payment_requests USING btree (provider, provider_payment_id) WHERE (provider_payment_id IS NOT NULL);\n\nCREATE UNIQUE INDEX uq_plg_conversations_webhook_events_event ON public.plg_conversations_webhook_events USING btree (provider, event_id);\n\n\n-- ── fk constraint ───────────────────────────────────────────────────────\n\nALTER TABLE ONLY public.plg_conversation_messages\n ADD CONSTRAINT plg_conversation_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.plg_conversations(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_conversation_messages\n ADD CONSTRAINT plg_conversation_messages_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_conversations_channels\n ADD CONSTRAINT plg_conversations_channels_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_conversations\n ADD CONSTRAINT plg_conversations_contact_person_id_fkey FOREIGN KEY (contact_person_id) REFERENCES public.people(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_conversations_optouts\n ADD CONSTRAINT plg_conversations_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_conversations_payment_requests\n ADD CONSTRAINT plg_conversations_payment_requests_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.plg_conversations(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_conversations_payment_requests\n ADD CONSTRAINT plg_conversations_payment_requests_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.plg_conversation_messages(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_conversations_payment_requests\n ADD CONSTRAINT plg_conversations_payment_requests_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_conversations\n ADD CONSTRAINT plg_conversations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\n\n-- ── row security ────────────────────────────────────────────────────────\n\nALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_conversations_channels ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_conversations_optouts ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_conversations_payment_requests ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_conversations_webhook_events ENABLE ROW LEVEL SECURITY;\n\n\n-- ── policy ──────────────────────────────────────────────────────────────\n\nCREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_conversations_channels_select ON public.plg_conversations_channels FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR (tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))));\n\nCREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_conversations_optouts_select ON public.plg_conversations_optouts FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR (tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))));\n\nCREATE POLICY plg_conversations_payment_requests_select ON public.plg_conversations_payment_requests FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\n\n-- ── acl ─────────────────────────────────────────────────────────────────\n\nREVOKE ALL ON FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid) TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_conversation_messages TO anon;\nGRANT ALL ON TABLE public.plg_conversation_messages TO authenticated;\nGRANT ALL ON TABLE public.plg_conversation_messages TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_conversations TO anon;\nGRANT ALL ON TABLE public.plg_conversations TO authenticated;\nGRANT ALL ON TABLE public.plg_conversations TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_conversations_channels TO anon;\nGRANT ALL ON TABLE public.plg_conversations_channels TO authenticated;\nGRANT ALL ON TABLE public.plg_conversations_channels TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_conversations_optouts TO anon;\nGRANT ALL ON TABLE public.plg_conversations_optouts TO authenticated;\nGRANT ALL ON TABLE public.plg_conversations_optouts TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_conversations_payment_requests TO anon;\nGRANT ALL ON TABLE public.plg_conversations_payment_requests TO authenticated;\nGRANT ALL ON TABLE public.plg_conversations_payment_requests TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_conversations_webhook_events TO anon;\nGRANT ALL ON TABLE public.plg_conversations_webhook_events TO authenticated;\nGRANT ALL ON TABLE public.plg_conversations_webhook_events TO service_role;\n\n\n-- ── default privileges: restore ─────────────────────────────────────────\n-- Put back, so the NEXT thing installed inherits what the chain had.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set — %', $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEND $dp$;\n`\n\nexport const MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO = `-- ---------------------------------------------------------------------------\n-- 001_a_caixa_sabe_quem_falou_por_ultimo.sql\n--\n-- A conversa guarda o TEXTO e a HORA da última mensagem, e não guarda de quem\n-- ela foi. Sem isso, \"quem está esperando resposta\" — que é a pergunta com que\n-- este produto abre o dia — não é respondível a partir da lista: seria preciso\n-- carregar as mensagens de cada thread para descobrir quem falou por último.\n--\n-- O painel respondia com \\`unread_count > 0\\`, que é a aproximação honesta que o\n-- shape permitia e não é a resposta certa. Ela erra exatamente no caso que mais\n-- dói: a thread que alguém ABRIU, leu, decidiu responder depois e esqueceu. O\n-- não-lida zera na leitura; o cliente continua esperando.\n--\n-- ── Por que TRIGGER e não mais uma coluna que cada escritor preenche ───────\n--\n-- \\`last_message_preview\\` e \\`last_message_at\\` são escritos hoje em CINCO lugares:\n-- o provider do navegador (criar conversa, enviar), a função \\`messaging-send\\` e\n-- o \\`tyxter-webhook\\` (duas vezes, contando o handler de pagamento). Uma sexta\n-- coluna com a mesma disciplina é uma sexta chance de alguém esquecer — e o\n-- modo de falhar é silencioso: a fila simplesmente para de listar alguém.\n--\n-- O gatilho tem UM escritor e não pode ser esquecido pelo próximo chamador. Ele\n-- é deliberadamente estreito: cuida só de \\`last_message_direction\\`. Trazer o\n-- preview e a hora para cá seria a correção certa e é uma mudança maior, com\n-- cinco chamadores para reconciliar — fica anotado, não feito aqui.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_conversations\n ADD COLUMN IF NOT EXISTS last_message_direction text;\n\nDO $$\nBEGIN\n ALTER TABLE public.plg_conversations\n ADD CONSTRAINT plg_conversations_last_message_direction_check\n CHECK (last_message_direction IS NULL\n OR last_message_direction = ANY (ARRAY['inbound'::text, 'outbound'::text]));\nEXCEPTION WHEN duplicate_object THEN\n NULL;\nEND $$;\n\nCOMMENT ON COLUMN public.plg_conversations.last_message_direction IS\n 'Quem falou por último: inbound = o cliente, outbound = a casa (001). Mantida por gatilho em plg_conversation_messages, nunca escrita à mão. NULL = thread anterior a esta migration cujo backfill não achou mensagem nenhuma.';\n\n-- ── o único escritor ───────────────────────────────────────────────────────\n--\n-- AFTER INSERT: a mensagem já existe quando a conversa é atualizada, então uma\n-- falha aqui não pode desfazer o recebimento — e recebimento perdido é pior que\n-- fila desatualizada.\n--\n-- Sem cláusula de ordem: a mensagem que acabou de entrar É a última. Comparar\n-- com \\`last_message_at\\` para decidir seria correto e traria uma corrida com os\n-- cinco escritores daquela coluna, que é justamente o problema que o gatilho\n-- existe para não ter.\nCREATE OR REPLACE FUNCTION public.plg_conversations_stamp_direction()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $function$\nBEGIN\n IF NEW.direction IS NULL THEN\n RETURN NULL;\n END IF;\n\n UPDATE public.plg_conversations\n SET last_message_direction = NEW.direction\n WHERE id = NEW.conversation_id;\n\n RETURN NULL;\nEND $function$;\n\nCOMMENT ON FUNCTION public.plg_conversations_stamp_direction() IS\n 'Carimba plg_conversations.last_message_direction a cada mensagem inserida (001). O único escritor da coluna.';\n\nDROP TRIGGER IF EXISTS plg_conversation_messages_stamp_direction ON public.plg_conversation_messages;\nCREATE TRIGGER plg_conversation_messages_stamp_direction\n AFTER INSERT ON public.plg_conversation_messages\n FOR EACH ROW\n EXECUTE FUNCTION public.plg_conversations_stamp_direction();\n\n-- ── backfill ───────────────────────────────────────────────────────────────\n--\n-- DISTINCT ON pega uma linha por conversa, a mais recente. \\`at DESC NULLS LAST\\`\n-- e depois \\`id DESC\\` porque \\`at\\` tem default e não NOT NULL: duas mensagens no\n-- mesmo instante (ou ambas sem hora) precisam de um desempate estável, ou o\n-- backfill devolve resultado diferente a cada execução.\nUPDATE public.plg_conversations c\n SET last_message_direction = m.direction\n FROM (\n SELECT DISTINCT ON (conversation_id) conversation_id, direction\n FROM public.plg_conversation_messages\n WHERE direction IS NOT NULL\n ORDER BY conversation_id, at DESC NULLS LAST, id DESC\n ) m\n WHERE m.conversation_id = c.id\n AND c.last_message_direction IS DISTINCT FROM m.direction;\n\n-- Só as threads que interessam à fila, e só quando o cliente falou por último:\n-- é o índice que a pergunta \"quem está esperando\" faz, e ele fica pequeno\n-- porque a maioria das conversas de um tenant está encerrada.\nCREATE INDEX IF NOT EXISTS idx_plg_conversations_waiting\n ON public.plg_conversations (tenant_id, last_message_at DESC)\n WHERE status = 'open' AND last_message_direction = 'inbound';\n`\n\nexport const MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI = `-- ---------------------------------------------------------------------------\n-- 002_a_casa_escolhe_por_onde_o_whatsapp_sai.sql — dois provedores de WhatsApp,\n-- a credencial de cada um, e a escolha de qual está valendo.\n--\n-- ── por que a credencial precisa morar aqui ─────────────────────────────\n--\n-- O \\`messaging-send\\` (WP3) pega a chave assim:\n--\n-- const credential = await redeemConnectorCredential({ connectorSlug: 'tyxter', … })\n--\n-- Quinze linhas que chamam a PLATAFORMA Fayz. Funciona para o Tyxter porque a\n-- chave é uma só, da FayaLabs, e a plataforma a serve para qualquer inquilino\n-- do projeto. Não funciona para o Evolution, e não vai funcionar nunca: o\n-- Evolution é auto-hospedado, e cada casa tem a própria URL e a própria chave.\n-- Credencial POR INQUILINO não é atalho, é requisito do segundo provedor.\n--\n-- Um app que não está ligado a projeto Fayz nenhum — como o FullControl hoje —\n-- também não resgata nada. Aqui a chave tem onde ficar nos dois casos.\n--\n-- ── os dois provedores não são a mesma coisa, e a tabela diz isso ───────\n--\n-- \\`tyxter\\` API oficial (Cloud API, via revenda). Iniciar conversa EXIGE\n-- template aprovado pela Meta; texto livre só dentro de 24h da\n-- última mensagem do cliente. Em troca: entrega confiável, sem\n-- risco de banimento, e a Meta responde por número e qualidade.\n-- \\`evolution\\` WhatsApp Web, auto-hospedado. Não tem template nem janela de\n-- 24h — manda o que quiser. Em troca: a sessão morre, o número\n-- pode ser banido, e ninguém responde por isso além de você.\n--\n-- \\`capabilities\\` guarda essa diferença como DADO e não como \\`if\\` espalhado pela\n-- tela. Um botão de \"submeter template\" num provedor que não tem template não\n-- pode existir; a tela lê daqui para desabilitar em vez de fingir.\n--\n-- ── o segredo não passa pelo navegador ──────────────────────────────────\n--\n-- A tabela nega SELECT a \\`authenticated\\`. Quem lê é o \\`service_role\\`, do lado\n-- do servidor. A tela sabe se EXISTE credencial e quais são os campos não\n-- secretos (a URL do Evolution, o nome da instância) por uma view; o segredo\n-- ela nunca vê, nem para reexibir mascarado.\n-- ---------------------------------------------------------------------------\n\n-- ── quem está valendo, por inquilino ───────────────────────────────────────\nCREATE TABLE IF NOT EXISTS public.plg_conversations_providers (\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n provider text NOT NULL,\n -- Não secreto: a URL base do Evolution, o nome da instância, o id do número.\n -- É isto que a tela mostra e deixa editar.\n config jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- Secreto. Nenhuma política de leitura alcança esta coluna.\n secrets jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- O que este provedor SABE fazer. Lido pela tela para desabilitar o que não\n -- existe, em vez de oferecer um botão que erra.\n capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- Um só por inquilino fica ativo. O outro continua configurado, pronto para\n -- a troca — que é o pedido: poder alternar sem reconfigurar.\n is_active boolean NOT NULL DEFAULT false,\n status text NOT NULL DEFAULT 'unconfigured',\n status_detail text,\n checked_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, provider),\n CONSTRAINT plg_conversations_providers_provider\n CHECK (provider IN ('tyxter', 'evolution')),\n CONSTRAINT plg_conversations_providers_status\n CHECK (status IN ('unconfigured', 'connecting', 'connected', 'error', 'disconnected'))\n);\n\n-- UM ativo por inquilino, garantido pelo banco e não pela tela. Duas telas\n-- abertas em abas diferentes são o caso comum, e \"o último clique vence\" só é\n-- verdade se alguém impedir o empate.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_providers_one_active\n ON public.plg_conversations_providers (tenant_id)\n WHERE is_active;\n\nALTER TABLE public.plg_conversations_providers ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_conversations_providers FORCE ROW LEVEL SECURITY;\n\n-- Nenhuma política para \\`authenticated\\`: o segredo mora numa coluna desta\n-- tabela, e uma política de SELECT aqui entregaria a chave ao navegador.\n--\n-- E o REVOKE explícito, porque a ausência de política NÃO é a única linha de\n-- defesa. O schema \\`public\\` deste cluster carrega grants amplos de fábrica —\n-- medido nesta própria tabela logo após criá-la: sete privilégios já\n-- concedidos a \\`authenticated\\` sem ninguém ter pedido. A RLS forçada barra\n-- assim mesmo, mas uma política escrita errada num dia ruim é tudo o que\n-- separa a chave do navegador. Duas fechaduras.\nREVOKE ALL ON public.plg_conversations_providers FROM PUBLIC, anon, authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_providers TO service_role;\n\n-- ── o que a tela pode ver ──────────────────────────────────────────────────\n--\n-- Tudo menos o segredo. \\`has_credential\\` responde a única pergunta que a tela\n-- precisa fazer sobre ele: já foi posto?\n-- ── A ÚNICA VIEW DESTE REPO QUE NÃO É \\`security_invoker\\` ──────────────────\n--\n-- A regra da casa é \\`security_invoker = true\\`: a view enxerga o que o leitor\n-- enxerga, e a RLS da tabela continua valendo. Aqui isso se autoderrota.\n--\n-- \\`authenticated\\` NÃO pode ler \\`plg_conversations_providers\\` — é onde o segredo\n-- mora, e o REVOKE logo acima é deliberado. Uma view invoker sobre uma tabela\n-- que o chamador não alcança devolve \\`42501 permission denied\\`, que foi\n-- exatamente o que a tela recebeu no primeiro teste com um JWT de verdade.\n--\n-- Então esta view é definer POR PROJETO, e paga o preço sendo ela mesma a\n-- fronteira: o \\`WHERE tenant_id IN (user_tenant_ids())\\` abaixo não é\n-- decoração, é a RLS desta view. E a coluna do segredo não está na lista do\n-- SELECT — o que ela não seleciona não existe para quem lê.\nDROP VIEW IF EXISTS public.v_conversations_providers;\nCREATE VIEW public.v_conversations_providers AS\n SELECT p.tenant_id,\n p.provider,\n p.config,\n p.capabilities,\n p.is_active,\n p.status,\n p.status_detail,\n p.checked_at,\n -- Nunca o valor. Só se existe.\n (p.secrets ? 'api_key') AS has_credential,\n p.created_at,\n p.updated_at\n FROM public.plg_conversations_providers p\n WHERE p.tenant_id IN (SELECT public.user_tenant_ids());\n\nGRANT SELECT ON public.v_conversations_providers TO authenticated;\n\nCOMMENT ON VIEW public.v_conversations_providers IS\n 'Os provedores de WhatsApp da casa, SEM o segredo. \\`has_credential\\` diz se a chave foi posta; o valor não sai daqui (002).';\n\n-- ── pôr a credencial ───────────────────────────────────────────────────────\n--\n-- SECURITY DEFINER porque a tabela nega escrita a \\`authenticated\\`: é a única\n-- porta, e ela confere o inquilino antes de abrir. \\`search_path\\` vazio porque\n-- uma função definer sem isso é uma função que o chamador pode redirecionar.\nCREATE OR REPLACE FUNCTION public.conversations_set_provider(\n p_tenant uuid,\n p_provider text,\n p_config jsonb DEFAULT '{}'::jsonb,\n p_api_key text DEFAULT NULL,\n p_capabilities jsonb DEFAULT NULL\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE v_caps jsonb;\nBEGIN\n IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN\n RAISE EXCEPTION 'conversations_set_provider: não é uma casa sua';\n END IF;\n\n -- O que cada provedor sabe fazer é conhecimento do produto, não do usuário.\n -- Quem chama pode sobrescrever (um Evolution atrás de proxy pode ganhar\n -- capacidades), mas o padrão descreve a verdade de cada um.\n v_caps := coalesce(p_capabilities, CASE p_provider\n WHEN 'tyxter' THEN jsonb_build_object(\n 'templates', true, -- e são OBRIGATÓRIOS para iniciar conversa\n 'requires_template', true,\n 'session_window_hours', 24, -- texto livre só dentro dela\n 'delivery_receipts', true,\n 'qr_pairing', false,\n 'ban_risk', 'low')\n WHEN 'evolution' THEN jsonb_build_object(\n 'templates', false, -- não existem: manda texto direto\n 'requires_template', false,\n 'session_window_hours', null,\n 'delivery_receipts', true,\n 'qr_pairing', true, -- parear é ler um QR, não cadastrar na Meta\n 'ban_risk', 'high') -- e é por isso que o aquecimento importa\n ELSE '{}'::jsonb END);\n\n INSERT INTO public.plg_conversations_providers AS t\n (tenant_id, provider, config, capabilities, status,\n secrets)\n VALUES\n (p_tenant, p_provider, coalesce(p_config, '{}'::jsonb), v_caps,\n CASE WHEN p_api_key IS NULL THEN 'unconfigured' ELSE 'connecting' END,\n CASE WHEN p_api_key IS NULL THEN '{}'::jsonb\n ELSE jsonb_build_object('api_key', p_api_key) END)\n ON CONFLICT (tenant_id, provider) DO UPDATE\n SET config = coalesce(p_config, t.config),\n capabilities = v_caps,\n -- Chave nula é \"não mexe\", não \"apaga\". Salvar a URL do Evolution sem\n -- redigitar a chave é o gesto comum, e apagá-la aqui seria a surpresa.\n secrets = CASE WHEN p_api_key IS NULL THEN t.secrets\n ELSE jsonb_set(t.secrets, '{api_key}', to_jsonb(p_api_key)) END,\n status = CASE WHEN p_api_key IS NULL THEN t.status ELSE 'connecting' END,\n updated_at = now();\nEND $$;\n\nREVOKE ALL ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) TO authenticated, service_role;\n\n-- ── trocar qual está valendo ───────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.conversations_activate_provider(\n p_tenant uuid, p_provider text\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nBEGIN\n IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN\n RAISE EXCEPTION 'conversations_activate_provider: não é uma casa sua';\n END IF;\n\n -- Desligar ANTES de ligar: o índice único parcial recusaria os dois ativos,\n -- e numa transação só a ordem é o que decide entre trocar e falhar.\n UPDATE public.plg_conversations_providers\n SET is_active = false, updated_at = now()\n WHERE tenant_id = p_tenant AND is_active;\n\n UPDATE public.plg_conversations_providers\n SET is_active = true, updated_at = now()\n WHERE tenant_id = p_tenant AND provider = p_provider;\n\n IF NOT FOUND THEN\n RAISE EXCEPTION 'conversations_activate_provider: % não está configurado nesta casa', p_provider;\n END IF;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.conversations_activate_provider(uuid, text) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.conversations_activate_provider(uuid, text) TO authenticated, service_role;\n\n-- ── o canal aprende de que provedor ele é ──────────────────────────────────\n--\n-- \\`plg_conversations_channels.provider\\` já existe e já guarda 'tyxter'. O que\n-- falta é a ligação com a credencial: um número do Evolution precisa saber de\n-- QUAL instância ele saiu, porque a mesma casa pode ter mais de uma.\nALTER TABLE public.plg_conversations_channels\n ADD COLUMN IF NOT EXISTS instance_key text;\n\nCOMMENT ON COLUMN public.plg_conversations_channels.instance_key IS\n 'A instância do provedor de onde este número sai. Nulo no Tyxter (uma conta por projeto); no Evolution é o nome da instância (002).';\n`\n\nexport const MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA = `-- ---------------------------------------------------------------------------\n-- 003_a_campanha_sai_no_ritmo_que_o_numero_aguenta.sql — template, campanha,\n-- log de envio e o ritmo que impede o número de ser banido.\n--\n-- O desenho não é invenção: saiu de ler Mautic, EspoCRM, Odoo e Twenty, e de\n-- ler a documentação da Meta em vez do folclore que circula sobre ela. Onde os\n-- quatro discordam, este arquivo diz qual escolheu e por quê.\n--\n-- ── três coisas que a Meta documenta e que viram REGRA, não ajuste ──────\n--\n-- · 131050 (o usuário saiu do marketing) NUNCA se repete. Reenviar é outra\n-- infração, não outra tentativa.\n-- · 131048 (bloqueado/marcado como spam) é parada dura. É o alarme de\n-- qualidade, não um erro de rede.\n-- · A janela de 24h só abre por mensagem OU chamada DO CLIENTE. Template\n-- nosso não abre janela nenhuma — e é o erro que mais se vê em CRM.\n--\n-- O resto — a rampa de aquecimento, a faixa de atraso entre mensagens — é\n-- prática de comunidade, não documentação. Vira PADRÃO EDITÁVEL: quem opera o\n-- número sabe mais do que uma tabela escrita hoje.\n--\n-- ── por que o log de envio tem \\`rotation\\` e \\`claim_token\\` ───────────────\n--\n-- \\`rotation\\` é do Mautic: a chave única é (campanha, contato, rotação), e a\n-- rotação sobe quando alguém REINICIA a campanha de propósito. Sem ela, a única\n-- forma de reenviar é apagar o histórico — e aí ninguém consegue provar o que\n-- foi mandado.\n--\n-- \\`claim_token\\` + \\`claim_expires_at\\` é do Twenty: quem vai enviar ARRENDA a\n-- linha por cinco minutos. Duas instâncias do disparador não mandam a mesma\n-- mensagem duas vezes, e um processo que morre no meio devolve a linha sozinho\n-- quando o arrendamento vence. Os quatro CRMs resolvem isso com índice único\n-- mais arrendamento; nenhum usa advisory lock, e nenhum usa outbox.\n-- ---------------------------------------------------------------------------\n\n-- ── quem nunca mais deve ser contatado ─────────────────────────────────────\n--\n-- A tabela já existe e já é chaveada pelo NÚMERO e não pelo contato — que é o\n-- acerto do Odoo e do Twenty, e o erro do Mautic: número é reatribuído, contato\n-- é duplicado, e a supressão precisa sobreviver aos dois.\n--\n-- O que falta é não apagar nunca. O EspoCRM apaga a linha ao reinscrever e\n-- perde a prova de que alguém um dia pediu para sair; o Odoo desliga um\n-- booleano. Seguimos o Odoo.\nALTER TABLE public.plg_conversations_optouts\n ADD COLUMN IF NOT EXISTS active boolean NOT NULL DEFAULT true,\n ADD COLUMN IF NOT EXISTS revoked_at timestamptz,\n ADD COLUMN IF NOT EXISTS source text;\n\nCOMMENT ON COLUMN public.plg_conversations_optouts.active IS\n 'Desligar em vez de apagar: a linha é a prova de que a pessoa pediu para sair, e apagá-la é perder a prova (003).';\n\n-- ── o texto que se manda ───────────────────────────────────────────────────\n--\n-- Um template aqui NÃO é um template da Meta. É o texto da casa, com o espelho\n-- do que o provedor respondeu sobre ele. Assim o mesmo registro serve aos dois:\n-- no Tyxter ele carrega o nome e o status da aprovação; no Evolution, que não\n-- tem o conceito, ele é só o texto — e a campanha não precisa saber a diferença.\nCREATE TABLE IF NOT EXISTS public.plg_conversations_templates (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n language text NOT NULL DEFAULT 'pt_BR',\n category text NOT NULL DEFAULT 'MARKETING',\n body text NOT NULL,\n -- Os nomes das variáveis, na ordem em que {{1}}, {{2}} aparecem. A Meta só\n -- entende posição; gente entende nome. Guardar os dois é o que deixa a tela\n -- pedir \"cargo\" em vez de \"variável 3\".\n variables text[] NOT NULL DEFAULT '{}',\n provider text,\n provider_template_id text,\n -- \\`draft\\` enquanto é nosso; depois é o que o provedor respondeu, verbatim.\n status text NOT NULL DEFAULT 'draft',\n status_detail text,\n submitted_at timestamptz,\n approved_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_templates_name_uq\n ON public.plg_conversations_templates (tenant_id, name, language);\n\nALTER TABLE public.plg_conversations_templates ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_conversations_templates FORCE ROW LEVEL SECURITY;\nDROP POLICY IF EXISTS plg_conversations_templates_rw ON public.plg_conversations_templates;\nCREATE POLICY plg_conversations_templates_rw ON public.plg_conversations_templates\n FOR ALL TO authenticated\n USING (tenant_id IN (SELECT public.user_tenant_ids()))\n WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_templates TO authenticated, service_role;\n\n-- ── a campanha ─────────────────────────────────────────────────────────────\nCREATE TABLE IF NOT EXISTS public.plg_conversations_campaigns (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n template_id uuid REFERENCES public.plg_conversations_templates(id) ON DELETE SET NULL,\n channel_id uuid REFERENCES public.plg_conversations_channels(id) ON DELETE SET NULL,\n provider text,\n status text NOT NULL DEFAULT 'draft',\n -- O ritmo. Nasce do padrão do provedor e é editável: a rampa é prática de\n -- comunidade, e quem opera o número sabe mais que esta tabela.\n pacing jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- Por que parou, quando parou sozinha. Ver \\`crm\\` nenhum: é o alarme de\n -- qualidade da Meta chegando na tela de quem disparou.\n paused_reason text,\n started_at timestamptz,\n finished_at timestamptz,\n created_by uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_conversations_campaigns_status\n CHECK (status IN ('draft', 'scheduled', 'running', 'paused', 'done', 'cancelled'))\n);\n\nALTER TABLE public.plg_conversations_campaigns ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_conversations_campaigns FORCE ROW LEVEL SECURITY;\nDROP POLICY IF EXISTS plg_conversations_campaigns_rw ON public.plg_conversations_campaigns;\nCREATE POLICY plg_conversations_campaigns_rw ON public.plg_conversations_campaigns\n FOR ALL TO authenticated\n USING (tenant_id IN (SELECT public.user_tenant_ids()))\n WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaigns TO authenticated, service_role;\n\n-- ── uma linha por destinatário, para sempre ────────────────────────────────\n--\n-- É o log de envio e a fila ao mesmo tempo. Vale para os dois provedores, e é\n-- o único lugar onde se pode PROVAR o que foi mandado, para quem, quando, e\n-- por que alguém NÃO foi contatado.\nCREATE TABLE IF NOT EXISTS public.plg_conversations_campaign_targets (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n campaign_id uuid NOT NULL REFERENCES public.plg_conversations_campaigns(id) ON DELETE CASCADE,\n -- A pessoa do CRM. \\`SET NULL\\` e não \\`CASCADE\\`: apagar a pessoa não pode\n -- apagar a prova de que uma mensagem foi enviada a ela.\n person_id uuid,\n phone_e164 text NOT NULL,\n -- Ver o cabeçalho: sobe quando alguém REINICIA a campanha de propósito.\n rotation integer NOT NULL DEFAULT 1,\n state text NOT NULL DEFAULT 'queued',\n skip_reason text,\n failure_code text,\n failure_detail text,\n -- O arrendamento. Só quem segura o token manda.\n claim_token uuid,\n claim_expires_at timestamptz,\n provider_message_id text,\n variables jsonb NOT NULL DEFAULT '{}'::jsonb,\n queued_at timestamptz NOT NULL DEFAULT now(),\n sent_at timestamptz,\n delivered_at timestamptz,\n read_at timestamptz,\n replied_at timestamptz,\n failed_at timestamptz,\n CONSTRAINT plg_conversations_campaign_targets_state\n CHECK (state IN ('queued', 'sending', 'sent', 'delivered', 'read', 'replied', 'failed', 'skipped')),\n -- O arrendamento existe se e só se está enviando. Duas verificações porque\n -- um token sem prazo é um token que nunca volta.\n CONSTRAINT plg_conversations_campaign_targets_lease\n CHECK (state <> 'sending' OR claim_expires_at IS NOT NULL),\n CONSTRAINT plg_conversations_campaign_targets_lease_pair\n CHECK ((claim_token IS NULL) = (claim_expires_at IS NULL))\n);\n\n-- Uma vez por campanha, por pessoa, por rodada. É esta linha que impede o\n-- disparo duplo — não um \\`if\\` no código do disparador.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_once\n ON public.plg_conversations_campaign_targets (campaign_id, phone_e164, rotation);\n\n-- O webhook do provedor chega mais de uma vez, de propósito (o Evolution tenta\n-- 10 vezes em ~50 min). A idempotência do recebimento é este índice.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_provider_msg\n ON public.plg_conversations_campaign_targets (tenant_id, provider_message_id)\n WHERE provider_message_id IS NOT NULL;\n\nCREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_pending\n ON public.plg_conversations_campaign_targets (campaign_id)\n WHERE state IN ('queued', 'sending');\n\n-- O ceifeiro: linhas cujo arrendamento venceu voltam para a fila.\nCREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_reaper\n ON public.plg_conversations_campaign_targets (claim_expires_at)\n WHERE state = 'sending';\n\nALTER TABLE public.plg_conversations_campaign_targets ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_conversations_campaign_targets FORCE ROW LEVEL SECURITY;\nDROP POLICY IF EXISTS plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets;\nCREATE POLICY plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets\n FOR ALL TO authenticated\n USING (tenant_id IN (SELECT public.user_tenant_ids()))\n WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaign_targets TO authenticated, service_role;\n\n-- ── o ritmo que o número aguenta ───────────────────────────────────────────\n--\n-- Mora no canal e não na campanha: o limite é do NÚMERO, e duas campanhas no\n-- mesmo número somam. Guardar na campanha deixaria cada uma achar que tem a\n-- cota inteira.\nALTER TABLE public.plg_conversations_channels\n ADD COLUMN IF NOT EXISTS pacing jsonb NOT NULL DEFAULT '{}'::jsonb,\n ADD COLUMN IF NOT EXISTS warmup_day integer,\n ADD COLUMN IF NOT EXISTS warmup_started_at timestamptz,\n ADD COLUMN IF NOT EXISTS quality text,\n ADD COLUMN IF NOT EXISTS quality_at timestamptz;\n\nCOMMENT ON COLUMN public.plg_conversations_channels.pacing IS\n 'Ritmo deste número: por minuto, por hora, por dia, faixa de atraso e janela de horário. Padrão por provedor, editável — a rampa é prática de comunidade, não documentação da Meta (003).';\n\n-- Os padrões, por provedor. Números da pesquisa; o comentário diz a\n-- procedência de cada um, porque \"por que 20?\" é a pergunta que sempre volta.\nCREATE OR REPLACE FUNCTION public.conversations_default_pacing(p_provider text)\nRETURNS jsonb LANGUAGE sql IMMUTABLE AS $$\n SELECT CASE p_provider\n -- Oficial. O teto real é o tier da Meta (250/2.000/10.000/100.000), que a\n -- própria API informa por número — então aqui só entra o que a Meta NÃO\n -- controla: o ritmo instantâneo. 1 msg / 6 s para o MESMO destinatário é\n -- documentado; 60/min é 75% do teto de 80/s por segurança.\n WHEN 'tyxter' THEN jsonb_build_object(\n 'per_minute', 60, 'per_hour', 1000, 'per_day', null,\n 'delay_seconds', jsonb_build_array(1, 3),\n 'same_recipient_seconds', 6,\n 'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))\n -- Não oficial. Aqui não há teto do provedor — há banimento. Os números são\n -- a moda de treze rampas brasileiras publicadas: 10-30/dia na primeira\n -- semana, 200-300/dia no regime. Atraso ALEATÓRIO entre 15 e 45 s porque\n -- intervalo fixo é assinatura de robô.\n WHEN 'evolution' THEN jsonb_build_object(\n 'per_minute', 12, 'per_hour', 300, 'per_day', 250,\n 'delay_seconds', jsonb_build_array(15, 45),\n 'same_recipient_seconds', 6,\n 'batch_size', 50, 'batch_cooldown_minutes', 12,\n 'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))\n ELSE '{}'::jsonb END\n$$;\n\nCOMMENT ON FUNCTION public.conversations_default_pacing(text) IS\n 'O ritmo inicial de cada provedor. Tyxter: o teto é da Meta, aqui só o ritmo instantâneo. Evolution: não há teto, há banimento (003).';\n\n-- A rampa de aquecimento, em dias. Prática de comunidade, editável — e a\n-- função existe para que o padrão seja UM lugar e não uma constante copiada.\nCREATE OR REPLACE FUNCTION public.conversations_warmup_allowance(p_day integer)\nRETURNS integer LANGUAGE sql IMMUTABLE AS $$\n SELECT CASE\n WHEN p_day IS NULL THEN NULL -- número sem aquecimento declarado: sem teto extra\n WHEN p_day <= 3 THEN 20\n WHEN p_day <= 7 THEN 50\n WHEN p_day <= 14 THEN 100\n WHEN p_day <= 21 THEN 200\n ELSE 300\n END\n$$;\n\nCOMMENT ON FUNCTION public.conversations_warmup_allowance(integer) IS\n 'Quantos destinatários NOVOS por dia, por dia de aquecimento. Moda de treze rampas brasileiras publicadas — comunidade, não Meta. Padrão editável (003).';\n\n-- ── o que a tela do disparo precisa ver ────────────────────────────────────\nDROP VIEW IF EXISTS public.v_conversations_campaign_progress;\nCREATE VIEW public.v_conversations_campaign_progress\nWITH (security_invoker = true) AS\n SELECT c.id AS campaign_id,\n c.tenant_id,\n c.name,\n c.status,\n c.paused_reason,\n c.provider,\n count(t.id) AS total,\n count(*) FILTER (WHERE t.state = 'queued') AS queued,\n count(*) FILTER (WHERE t.state = 'sending') AS sending,\n count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) AS sent,\n count(*) FILTER (WHERE t.state = 'delivered') AS delivered,\n count(*) FILTER (WHERE t.state = 'read') AS read,\n count(*) FILTER (WHERE t.state = 'replied') AS replied,\n count(*) FILTER (WHERE t.state = 'failed') AS failed,\n count(*) FILTER (WHERE t.state = 'skipped') AS skipped,\n -- A taxa de resposta é o sinal que decide se o número sobrevive: a\n -- prática diz que abaixo de 30% o risco de banimento sobe. Fica na\n -- view porque é o número que precisa estar na tela, não num relatório.\n CASE WHEN count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) > 0\n THEN round(100.0 * count(*) FILTER (WHERE t.state = 'replied')\n / count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')), 1)\n END AS reply_rate,\n min(t.sent_at) AS first_sent_at,\n max(t.sent_at) AS last_sent_at\n FROM public.plg_conversations_campaigns c\n LEFT JOIN public.plg_conversations_campaign_targets t ON t.campaign_id = c.id\n WHERE c.tenant_id IN (SELECT public.user_tenant_ids())\n GROUP BY c.id, c.tenant_id, c.name, c.status, c.paused_reason, c.provider;\n\nGRANT SELECT ON public.v_conversations_campaign_progress TO authenticated;\n\n-- ── arrendar o próximo lote ────────────────────────────────────────────────\n--\n-- \\`FOR UPDATE SKIP LOCKED\\` é o que deixa dois disparadores trabalharem a mesma\n-- campanha sem pisar um no outro, e sem advisory lock. O arrendamento de cinco\n-- minutos é o que devolve a linha quando um deles morre no meio.\n--\n-- A supressão é conferida AQUI, na mesma transação que arrenda. Conferir antes\n-- e mandar depois deixa uma janela em que alguém pede para sair e recebe assim\n-- mesmo — e é justamente essa mensagem que vira denúncia.\nCREATE OR REPLACE FUNCTION public.conversations_claim_targets(\n p_campaign uuid, p_limit integer DEFAULT 10\n) RETURNS TABLE (\n id uuid, phone_e164 text, person_id uuid, variables jsonb, claim_token uuid\n)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE v_token uuid := gen_random_uuid(); v_tenant uuid;\nBEGIN\n SELECT c.tenant_id INTO v_tenant\n FROM public.plg_conversations_campaigns c WHERE c.id = p_campaign;\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'campanha não encontrada'; END IF;\n\n -- TRÊS COMANDOS, E NÃO UM. A primeira versão fazia o ceifeiro, a supressão e\n -- o arrendamento em CTEs do mesmo UPDATE, e isso é uma armadilha: dentro de\n -- um comando só, todas as CTEs enxergam o MESMO instantâneo da tabela, e uma\n -- linha atualizada por duas delas recebe só a primeira — em silêncio. O\n -- suprimido vinha marcado E arrendado, e qual dos dois vencia dependia do\n -- plano que o Postgres escolhesse naquele dia.\n --\n -- Separado, cada passo enxerga o resultado do anterior. É mais lento por um\n -- par de milissegundos e é a diferença entre uma regra e uma coincidência.\n\n -- 1. O ceifeiro. Quem venceu o arrendamento volta para a fila, senão uma\n -- campanha inteira fica presa num processo que morreu.\n UPDATE public.plg_conversations_campaign_targets t\n SET state = 'queued', claim_token = NULL, claim_expires_at = NULL\n WHERE t.campaign_id = p_campaign AND t.state = 'sending'\n AND t.claim_expires_at < now();\n\n -- 2. Quem pediu para sair NUNCA entra num lote. A linha fica marcada em vez\n -- de sumir: é assim que se prova que a decisão foi deliberada.\n UPDATE public.plg_conversations_campaign_targets t\n SET state = 'skipped', skip_reason = 'suppressed'\n WHERE t.campaign_id = p_campaign AND t.state = 'queued'\n AND EXISTS (SELECT 1 FROM public.plg_conversations_optouts o\n WHERE o.tenant_id = t.tenant_id\n AND o.phone_e164 = t.phone_e164\n AND o.active);\n\n -- 3. Só então arrenda. \\`SKIP LOCKED\\` deixa dois disparadores trabalharem a\n -- mesma campanha sem pisar um no outro, e sem advisory lock.\n RETURN QUERY\n WITH escolhidos AS (\n SELECT t.id FROM public.plg_conversations_campaign_targets t\n WHERE t.campaign_id = p_campaign AND t.state = 'queued'\n ORDER BY t.queued_at\n LIMIT greatest(1, least(coalesce(p_limit, 10), 100))\n FOR UPDATE SKIP LOCKED\n )\n UPDATE public.plg_conversations_campaign_targets t\n SET state = 'sending', claim_token = v_token,\n claim_expires_at = now() + interval '5 minutes'\n FROM escolhidos e\n WHERE t.id = e.id\n RETURNING t.id, t.phone_e164, t.person_id, t.variables, t.claim_token;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;\n`\n\nexport const MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER = `-- ---------------------------------------------------------------------------\n-- 004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever.sql — a janela de 24h,\n-- como DADO, para a tela responder antes de alguém digitar.\n--\n-- ── o defeito que isto conserta ─────────────────────────────────────────\n--\n-- Na API oficial do WhatsApp, texto livre só sai dentro de 24h contadas da\n-- última mensagem DO CLIENTE. Fora dela a Meta recusa, e a recusa chega como um\n-- código no log — não na tela de quem escreveu. O atendente digita, aperta\n-- enviar, vê a mensagem aparecer na conversa, e ela nunca chega.\n--\n-- Isso é pior que um erro: é uma tela que mente. E some inteiro num provedor\n-- não oficial, onde não existe janela nenhuma — então o MESMO campo de texto\n-- funciona ou falha calado dependendo de qual provedor está ativo.\n--\n-- A correção não é um aviso depois. É a tela saber ANTES: janela aberta, texto\n-- livre; janela fechada, só template, e o campo diz isso em vez de aceitar.\n--\n-- ── quem abre a janela ──────────────────────────────────────────────────\n--\n-- O CLIENTE, e só ele. Mensagem nossa não abre nada — nem template aprovado.\n-- É o erro mais comum de quem implementa isso, e é por isso que o cálculo\n-- abaixo olha exclusivamente \\`direction = 'inbound'\\`.\n--\n-- (Origem: developers.facebook.com/documentation/business-messaging/whatsapp —\n-- \"The user messages you about the product. This opens a 24 hour customer\n-- service window.\" Cliques em anúncio Click-to-WhatsApp abrem 72h; não\n-- distinguimos ainda, e 24h é o limite conservador dos dois.)\n-- ---------------------------------------------------------------------------\n\nDROP VIEW IF EXISTS public.v_conversations_window;\nCREATE VIEW public.v_conversations_window\nWITH (security_invoker = true) AS\n SELECT c.id AS conversation_id,\n c.tenant_id,\n c.channel,\n c.contact_handle,\n c.contact_person_id,\n u.last_inbound_at,\n -- Quando fecha. Nulo = nunca abriu, que é o caso de todo lead que\n -- ainda não respondeu — a maioria de uma lista de feira.\n (u.last_inbound_at + interval '24 hours') AS window_expires_at,\n (u.last_inbound_at IS NOT NULL\n AND u.last_inbound_at > now() - interval '24 hours') AS window_open,\n -- Quanto falta, em minutos. A tela mostra \"faltam 3h\" e não um\n -- carimbo de data: quem está respondendo quer saber se dá tempo.\n CASE WHEN u.last_inbound_at IS NOT NULL\n THEN greatest(0, floor(extract(epoch FROM\n (u.last_inbound_at + interval '24 hours') - now()) / 60))::int\n END AS minutes_left,\n -- Quem pediu para não ser mais contatado. Some da tela antes de\n -- qualquer janela: não importa se está aberta.\n EXISTS (SELECT 1 FROM public.plg_conversations_optouts o\n WHERE o.tenant_id = c.tenant_id\n AND o.phone_e164 = regexp_replace(coalesce(c.contact_handle,''), '\\\\D', '', 'g')\n AND o.active) AS opted_out\n FROM public.plg_conversations c\n LEFT JOIN LATERAL (\n SELECT max(m.at) AS last_inbound_at\n FROM public.plg_conversation_messages m\n WHERE m.conversation_id = c.id\n AND m.direction = 'inbound'\n ) u ON true\n WHERE c.tenant_id IN (SELECT public.user_tenant_ids());\n\nGRANT SELECT ON public.v_conversations_window TO authenticated;\n\nCOMMENT ON VIEW public.v_conversations_window IS\n 'Se dá para mandar texto livre para esta conversa agora. A janela de 24h só abre por mensagem DO CLIENTE — template nosso não abre nada (004).';\n\n-- O índice que a LATERAL usa. Sem ele, cada linha da caixa varre as mensagens\n-- da conversa inteira, e a caixa é justamente a tela que abre o dia todo.\nCREATE INDEX IF NOT EXISTS plg_conversation_messages_inbound_at\n ON public.plg_conversation_messages (conversation_id, at DESC)\n WHERE direction = 'inbound';\n`\n\nexport const MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO = `-- ---------------------------------------------------------------------------\n-- 005_a_conversa_conta_ao_funil_que_houve_contato.sql — a ponte que faltava.\n--\n-- ── o buraco, medido ────────────────────────────────────────────────────\n--\n-- No cluster: 8 conversas, 42 mensagens trocadas — incluindo respostas de\n-- gente de verdade — e 315 negócios TODOS parados em \"Novo (feira)\", com\n-- \\`contact_status = 'not_contacted'\\`. Zero atividades vindas da caixa.\n--\n-- Não era defeito: não existia ligação nenhuma. Nenhum \\`INSERT\\` em\n-- \\`plg_crm_activities\\` fora do plugin-crm, nenhuma fonte de linha do tempo\n-- registrada. A caixa de entrada e o funil olhavam para as mesmas pessoas e\n-- não se falavam.\n--\n-- O maquinário do CRM já estava pronto e correto: \\`crm_recompute_deal_status\\`\n-- (crm/020) lê as atividades, pega o ÚLTIMO \\`outcome\\` e deriva o status; o\n-- gatilho \\`crm_activity_restatus\\` dispara a cada atividade inserida. Faltava\n-- alguém inserir.\n--\n-- ── por que o \\`outcome\\` é o que é ───────────────────────────────────────\n--\n-- mensagem RECEBIDA → \\`replied\\` o lead respondeu. É o fato mais forte\n-- que a caixa produz, e o que mais move\n-- o status (\"Respondeu\").\n-- mensagem ENVIADA → \\`left_message\\` deixamos recado. Mandar NÃO é ser\n-- lido: tratar envio como contato feito\n-- encheria o funil de \"conectado\" sem\n-- ninguém do outro lado. A 020 mapeia\n-- este valor para \"Não atendeu\", que é\n-- literalmente o estado — ainda sem\n-- resposta.\n--\n-- O vocabulário é FECHADO por CHECK (crm/020:49): \\`connected, no_answer,\n-- left_message, replied, scheduled\\`. Conferido antes de escrever — a primeira\n-- versão desta migration usava \\`attempted\\`, que a regra da 020 nomeia mas a\n-- restrição da coluna NÃO aceita, e teria falhado em toda mensagem enviada.\n--\n-- ── e o \\`deal_id\\` não é opcional ────────────────────────────────────────\n--\n-- \\`crm_recompute_deal_status\\` agrega \\`WHERE a.deal_id = p_deal_id\\`. Uma\n-- atividade só com \\`lead_id\\` não entra na conta e não move status nenhum —\n-- por isso o gatilho procura o negócio do lead e o carimba.\n--\n-- ── uma atividade por CONVERSA E POR DIA, não por mensagem ──────────────\n--\n-- Uma conversa de trinta mensagens não são trinta contatos: é um. Registrar\n-- cada mensagem encheria a linha do tempo de ruído e afogaria o que importa —\n-- a ligação, a visita, a proposta. Então a primeira mensagem do dia abre a\n-- atividade e as seguintes ATUALIZAM a mesma linha (o corpo mais recente, e o\n-- \\`outcome\\` mais forte do dia).\n--\n-- Atualizar também dispara o \\`crm_activity_restatus\\` (o gatilho é INSERT OR\n-- UPDATE), então uma resposta que chega depois de um envio move o status no\n-- mesmo dia, que é o comportamento que se quer.\n--\n-- ── e se o contato não for um lead ──────────────────────────────────────\n--\n-- \\`contact_person_id\\` é nulo enquanto ninguém casou o telefone com uma pessoa.\n-- Aí não há a quem pendurar o fato e a função sai calada. Não inventa lead: a\n-- caixa recebe mensagem de entregador, de banco e de engano, e cada um deles\n-- viraria um card no funil de alguém.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.conversations_log_crm_activity() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid;\n v_person uuid;\n v_canal text;\n v_deal uuid;\n v_outcome text;\n v_titulo text;\n v_corpo text;\n v_dia date := (NEW.at AT TIME ZONE 'UTC')::date;\n v_ativ uuid;\nBEGIN\n -- Sem CRM montado não há o que escrever. \\`to_regclass\\` em vez de referência\n -- direta porque conversas é plugin OPCIONAL e pode viver num cluster que não\n -- tem funil nenhum.\n IF to_regclass('public.plg_crm_activities') IS NULL THEN RETURN NULL; END IF;\n\n SELECT c.tenant_id, c.contact_person_id, c.channel\n INTO v_tenant, v_person, v_canal\n FROM public.plg_conversations c WHERE c.id = NEW.conversation_id;\n\n IF v_person IS NULL THEN RETURN NULL; END IF;\n\n SELECT d.id INTO v_deal\n FROM public.plg_crm_deals d\n WHERE d.tenant_id = v_tenant AND d.lead_id = v_person\n ORDER BY d.created_at DESC LIMIT 1;\n\n IF NEW.direction = 'inbound' THEN\n v_outcome := 'replied';\n v_titulo := 'Respondeu no ' || coalesce(v_canal, 'canal');\n ELSE\n v_outcome := 'left_message';\n v_titulo := 'Mensagem enviada no ' || coalesce(v_canal, 'canal');\n END IF;\n\n v_corpo := left(coalesce(NEW.body, ''), 2000);\n\n -- A atividade do dia, se já houver.\n SELECT a.id INTO v_ativ\n FROM public.plg_crm_activities a\n WHERE a.tenant_id = v_tenant\n AND a.lead_id = v_person\n AND a.activity_type = 'whatsapp'\n AND (a.completed_at AT TIME ZONE 'UTC')::date = v_dia\n ORDER BY a.completed_at DESC LIMIT 1;\n\n IF v_ativ IS NULL THEN\n INSERT INTO public.plg_crm_activities (\n tenant_id, deal_id, lead_id, contact_id, activity_type,\n title, description, outcome, completed_at, created_by_name\n ) VALUES (\n v_tenant, v_deal, v_person, v_person, 'whatsapp',\n v_titulo, v_corpo, v_outcome, NEW.at,\n -- Quem fez foi a caixa, não uma pessoa: o autor em uuid seria mentira,\n -- e \"(sistema)\" é a resposta honesta para quem lê a linha do tempo.\n 'Caixa de entrada'\n );\n ELSE\n UPDATE public.plg_crm_activities a SET\n -- \\`replied\\` NUNCA é rebaixado por um envio posterior: o lead ter\n -- respondido é o fato do dia, e mandar outra mensagem depois não o\n -- desfaz.\n outcome = CASE WHEN a.outcome = 'replied' THEN 'replied' ELSE v_outcome END,\n title = CASE WHEN a.outcome = 'replied' THEN a.title ELSE v_titulo END,\n description = v_corpo,\n completed_at = NEW.at,\n deal_id = coalesce(a.deal_id, v_deal)\n WHERE a.id = v_ativ;\n END IF;\n\n RETURN NULL;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.conversations_log_crm_activity() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.conversations_log_crm_activity() TO authenticated, service_role;\n\nCOMMENT ON FUNCTION public.conversations_log_crm_activity() IS\n 'Uma mensagem vira contato na linha do tempo do lead e do negócio, e o status do card deriva disso pelo gatilho da crm/020. Uma atividade por conversa por dia (005).';\n\n-- AFTER: falhar ao registrar no CRM não pode recusar uma mensagem que já\n-- chegou do provedor. A caixa é a fonte; o funil é quem escuta.\nDROP TRIGGER IF EXISTS conversations_log_crm_activity ON public.plg_conversation_messages;\nCREATE TRIGGER conversations_log_crm_activity\n AFTER INSERT ON public.plg_conversation_messages\n FOR EACH ROW EXECUTE FUNCTION public.conversations_log_crm_activity();\n\n-- O índice que a busca da atividade do dia usa. Sem ele, cada mensagem varre\n-- as atividades do inquilino inteiro — e mensagem é o que mais chega aqui.\nDO $$\nBEGIN\n -- Guarda pela mesma razão da função: conversas é opcional e pode viver sem\n -- CRM. Um índice sobre tabela que não existe recusa o arquivo inteiro.\n IF to_regclass('public.plg_crm_activities') IS NULL THEN RETURN; END IF;\n CREATE INDEX IF NOT EXISTS plg_crm_activities_lead_day\n ON public.plg_crm_activities (tenant_id, lead_id, activity_type, completed_at DESC);\nEND $$;\n`\n\nexport const MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU = `-- ---------------------------------------------------------------------------\n-- 006_a_caixa_avisa_que_chegou.sql\n--\n-- ── o silêncio, e de onde ele vinha ─────────────────────────────────────\n--\n-- A caixa recebia mensagens e ninguém era avisado. Sem som, sem contagem no\n-- menu, sem destaque na lista — e a suspeita natural é que falta a tela que\n-- avisa. Não faltava: o evento nunca saía do banco.\n--\n-- \\`plg_conversation_messages\\` e \\`plg_conversations\\` não estavam em\n-- \\`supabase_realtime\\`. A publicação existe (\\`puballtables = false\\`, ou seja,\n-- cada tabela entra nomeada) e nenhuma das duas fora nomeada. Qualquer cliente\n-- que assinasse \\`postgres_changes\\` sobre elas ficaria subscrito para sempre a\n-- um canal por onde nada passa — e subscrever com sucesso a um canal mudo é o\n-- modo de falhar mais convincente que há: o código parece certo, o console\n-- fica limpo, e nada acontece.\n--\n-- ── as duas, e não só as mensagens ──────────────────────────────────────\n--\n-- A mensagem é o que dispara o alerta; a CONVERSA é o que carrega o estado que\n-- a lista pinta (\\`status\\`, \\`unread_count\\`, \\`last_message_direction\\`). Publicar\n-- só a primeira daria o som sem o badge parar de mentir depois de alguém ler.\n--\n-- ── o RLS continua a valer ──────────────────────────────────────────────\n--\n-- Publicar não abre nada: o Realtime do Supabase aplica as políticas da tabela\n-- por assinante, e quem não pode ler a linha não a recebe. O que a publicação\n-- decide é se o evento EXISTE, não quem o vê.\n--\n-- ── idempotente, e silenciosa onde não se aplica ────────────────────────\n--\n-- \\`ALTER PUBLICATION ... ADD TABLE\\` rebenta se a tabela já lá está, e a\n-- publicação \\`supabase_realtime\\` só existe num Postgres da Supabase. As duas\n-- condições são verificadas antes, porque esta migration também roda no banco\n-- de teste, que não tem nenhuma delas.\n-- ---------------------------------------------------------------------------\n\nDO $$\nDECLARE\n t text;\n v_n integer := 0;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN\n RAISE NOTICE 'conversations/006: sem publicação supabase_realtime — nada a fazer';\n RETURN;\n END IF;\n\n FOREACH t IN ARRAY ARRAY['plg_conversation_messages', 'plg_conversations'] LOOP\n CONTINUE WHEN to_regclass('public.' || t) IS NULL;\n CONTINUE WHEN EXISTS (\n SELECT 1 FROM pg_publication_tables\n WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = t);\n EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', t);\n v_n := v_n + 1;\n END LOOP;\n\n RAISE NOTICE 'conversations/006: % tabela(s) publicada(s) no realtime', v_n;\nEND $$;\n\n-- \\`REPLICA IDENTITY FULL\\` na conversa: sem ela o UPDATE chega com a chave\n-- primária e mais nada, e a tela não consegue saber que \\`unread_count\\` mudou —\n-- que é exatamente o UPDATE que interessa aqui. Na tabela de mensagens não é\n-- preciso: o que importa é o INSERT, e esse vem inteiro de qualquer forma.\nDO $$\nBEGIN\n IF to_regclass('public.plg_conversations') IS NOT NULL THEN\n ALTER TABLE public.plg_conversations REPLICA IDENTITY FULL;\n END IF;\nEND $$;\n\n-- ── e a conta de não lidas, que ninguém mantinha ────────────────────────\n--\n-- \\`unread_count\\` estava em ZERO nas oito conversas do cluster, incluindo as\n-- quatro cuja última mensagem é de entrada. Não era um zero verdadeiro: não há\n-- (não havia) nada que a incremente. A coluna existe desde a baseline, a tela\n-- da lista decide o destaque por ela (\\`unreadCount > 0\\`) e a RPC de marcar como\n-- lida zera-a — só faltava o lado que soma.\n--\n-- Por isso \\`isWaitingOnUs\\` recorre a \\`lastMessageDirection\\` quando a conta é\n-- zero: foi escrita já sabendo que a conta não era de fiar. Esta migration\n-- torna-a de fiar, e a recorrência volta a ser o que devia ser — uma rede, e\n-- não o caminho normal.\n--\n-- Só o que CHEGA conta. Somar o que nós enviamos faria a caixa dizer que há\n-- doze por ler logo depois de alguém responder doze vezes.\nCREATE OR REPLACE FUNCTION public.plg_conversations_bump_unread() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\n AS $$\nBEGIN\n IF NEW.direction IS DISTINCT FROM 'inbound' THEN RETURN NULL; END IF;\n UPDATE public.plg_conversations\n SET unread_count = coalesce(unread_count, 0) + 1\n WHERE id = NEW.conversation_id;\n RETURN NULL;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.plg_conversations_bump_unread() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.plg_conversations_bump_unread() TO authenticated, service_role;\n\nCOMMENT ON FUNCTION public.plg_conversations_bump_unread() IS\n 'Soma uma não lida por mensagem que CHEGA. A coluna existia e era zerada ao ler, mas nada a incrementava (006).';\n\n-- AFTER, como o irmão que carimba a direção: falhar a contar não pode recusar\n-- uma mensagem que o provedor já entregou.\nDROP TRIGGER IF EXISTS plg_conversation_messages_bump_unread ON public.plg_conversation_messages;\nCREATE TRIGGER plg_conversation_messages_bump_unread\n AFTER INSERT ON public.plg_conversation_messages\n FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_bump_unread();\n\n-- O retroativo, e o que ele pode e não pode saber.\n--\n-- Não há coluna de \"lido até quando\" nesta tabela — \\`markRead\\` zera a conta e\n-- mais nada. Então não dá para reconstituir quantas mensagens ficaram por ler:\n-- o que dá para saber é que a conversa ESTÁ à espera, porque a última mensagem\n-- dela é de entrada.\n--\n-- Nesse caso a conta vira 1. Um número inventado seria pior que um número\n-- modesto: 1 diz \"há algo aqui\", que é verdade e é o que faz a linha destacar;\n-- dizer \"7\" alegaria uma precisão que esta tabela nunca guardou. A partir de\n-- agora o gatilho conta de verdade.\nUPDATE public.plg_conversations\n SET unread_count = 1\n WHERE last_message_direction = 'inbound'\n AND status = 'open'\n AND coalesce(unread_count, 0) = 0;\n`\n\nexport const MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD = `-- ---------------------------------------------------------------------------\n-- 007_a_conversa_diz_de_quem_e_o_lead.sql\n--\n-- ── duas coisas chamadas \"responsável\" ──────────────────────────────────\n--\n-- A caixa já mostra um \"Responsável\", e ele é \\`plg_conversations.assigned_to\\`:\n-- quem está ATENDENDO aquela conversa. Não é o dono do lead.\n--\n-- Os dois convivem e precisam conviver: a Carolina é dona do lead, e quem\n-- responde a mensagem que chegou às 2h da manhã pode ser outra pessoa. Colapsar\n-- num só campo faria uma das duas perguntas ficar sem resposta — e a que\n-- desapareceria é a do funil, que é a que decide quem faz o follow-up.\n--\n-- Esta view acrescenta a SEGUNDA, com nome próprio (\\`lead_owner_*\\`), sem tocar\n-- na primeira.\n--\n-- ── por que view, e não uma coluna na conversa ──────────────────────────\n--\n-- O dono do lead muda — alguém clica \"Assumir\" no funil e a carteira troca de\n-- mãos. Uma coluna copiada aqui seria uma segunda verdade que envelhece em\n-- silêncio: a conversa diria Carolina depois de o lead já ser do Julio, e\n-- ninguém saberia dizer qual das duas telas está certa.\n--\n-- É exatamente o problema que a 047 acabou de resolver do outro lado, onde o\n-- dono estava escrito em dois lugares que não se falavam. Derivar é o que\n-- impede a repetição.\n--\n-- ── e por que \\`v_leads\\` e não \\`plg_crm_deals\\` ───────────────────────────\n--\n-- \\`v_leads.assigned_to_id\\` já resolve a precedência entre o que a PESSOA\n-- declara (\\`metadata.assignedToId\\`) e o que o NEGÓCIO diz (047). Ir direto ao\n-- negócio aqui reimplementaria essa regra, e as duas divergiriam no dia em que\n-- uma mudasse.\n--\n-- ── o CRM é opcional ────────────────────────────────────────────────────\n--\n-- \\`plugin-conversations\\` roda em casa sem funil nenhum. \\`v_leads\\` é da espinha\n-- (não do CRM), então a dependência é legítima — mas a junção é LEFT: uma\n-- conversa de quem não é lead continua aparecendo, com o dono nulo.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE VIEW public.v_conversations WITH (security_invoker = true) AS\n SELECT c.*,\n l.assigned_to_id AS lead_owner_id,\n p.name AS lead_owner_name\n FROM public.plg_conversations c\n -- Pelo contato: é \\`contact_person_id\\` que liga a conversa a uma pessoa da\n -- base. Nulo enquanto ninguém casou o telefone com alguém, e aí não há\n -- lead a quem perguntar o dono.\n LEFT JOIN public.v_leads l\n ON l.id = c.contact_person_id\n -- O NOME do dono, que é o que a tela mostra. O id sozinho obrigaria cada\n -- tela a resolver o mesmo de-para — foi o que fez o filtro de leads exibir\n -- UUID em produção por um dia.\n LEFT JOIN public.v_team_members m\n ON m.user_id::text = l.assigned_to_id AND m.tenant_id = c.tenant_id\n LEFT JOIN public.people p\n ON p.id = m.person_id;\n\nCOMMENT ON VIEW public.v_conversations IS\n 'A conversa com o DONO DO LEAD junto — diferente de \\`assigned_to\\`, que é quem atende a conversa. Derivado de \\`v_leads\\` para não virar uma segunda verdade que envelhece (007).';\n`\n\nexport const MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO = `-- ---------------------------------------------------------------------------\n-- 008_dar_por_vista_e_uma_decisao.sql\n--\n-- ── o buraco ────────────────────────────────────────────────────────────\n--\n-- A bolinha vermelha da caixa não é \"não lido\": é \\`isWaitingOnUs\\`, que olha a\n-- DIREÇÃO da última mensagem. Se a última é de entrada, a conversa espera\n-- resposta — e continua esperando por mais que alguém abra e leia.\n--\n-- Isso está certo enquanto a pergunta é \"o que falta responder\". Mas deixa sem\n-- saída o caso comum: a pessoa leu, decidiu que não cabe resposta (\"ok\",\n-- \"obrigado\", um áudio que já foi tratado por telefone) e não tem como tirar a\n-- conversa da fila. \\`unread_count = 0\\` não resolve, porque não é ele que\n-- acende a bolinha.\n--\n-- ── por que um instante, e não um booleano ──────────────────────────────\n--\n-- \\`lida boolean\\` exigiria alguém lembrar de apagá-la quando chegasse mensagem\n-- nova — e o dia em que esquecessem, a conversa sumiria da fila com o cliente\n-- esperando. É o pior defeito possível numa caixa de entrada: silencioso, e do\n-- lado de perder trabalho.\n--\n-- Um INSTANTE se compara com \\`last_message_at\\` e se invalida sozinho: deu por\n-- vista às 14h, chegou mensagem às 15h, a conversa volta à fila sem ninguém\n-- fazer nada. A regra é uma comparação, não um estado a manter.\n--\n-- ── o que NÃO muda ──────────────────────────────────────────────────────\n--\n-- \\`unread_count\\` continua onde está. Ele conta mensagens e serve ao contador\n-- do menu; esta coluna responde outra pergunta (\"alguém já decidiu sobre\n-- isto?\"). Colapsar as duas faria a contagem do menu mentir no dia em que\n-- alguém desse por vista sem ler.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_conversations\n ADD COLUMN IF NOT EXISTS acknowledged_at timestamptz;\n\nCOMMENT ON COLUMN public.plg_conversations.acknowledged_at IS\n 'Quando alguém deu a conversa por vista. Vale enquanto for POSTERIOR a \\`last_message_at\\` — mensagem nova devolve a conversa à fila sozinha (008).';\n\n-- A fila é lida por \\`last_message_at\\` e por esta coluna juntas.\nCREATE INDEX IF NOT EXISTS plg_conversations_fila_idx\n ON public.plg_conversations (tenant_id, status, last_message_at DESC)\n WHERE status = 'open';\n`\n\nexport const MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME = `-- ---------------------------------------------------------------------------\n-- 009_a_mensagem_inteira_atravessa_o_realtime.sql\n--\n-- ── o buraco ────────────────────────────────────────────────────────────\n--\n-- O vigia da caixa assinava \\`plg_conversation_messages\\`, recebia \\`SUBSCRIBED\\`\n-- do servidor, e nunca recebia um único evento. Nenhum erro, nenhum aviso: a\n-- assinatura dizia que estava tudo bem e não chegava nada.\n--\n-- A causa é a combinação de duas coisas que, sozinhas, estão certas:\n--\n-- 1. a tabela tem RLS ligada, como toda tabela desta base;\n-- 2. a tabela estava com \\`REPLICA IDENTITY\\` no padrão — só a chave primária.\n--\n-- O Realtime do Supabase aplica a POLÍTICA DE LEITURA do usuário a cada evento\n-- antes de entregá-lo. Para isso ele precisa da linha inteira: a política fala\n-- de \\`tenant_id\\`, e \\`tenant_id\\` não é a chave primária. Com só a PK no WAL, não\n-- há o que avaliar — e o evento é descartado sem ruído.\n--\n-- \\`plg_conversations\\` já estava \\`FULL\\` (006). A tabela de MENSAGENS ficou de\n-- fora daquela migration, e é justamente ela que o vigia escuta.\n--\n-- ── por que isso é caro e ainda assim certo ─────────────────────────────\n--\n-- \\`FULL\\` faz o WAL carregar a linha inteira em cada UPDATE e DELETE, não só as\n-- colunas mudadas. Numa tabela de mensagens isso é peso real.\n--\n-- A alternativa — desligar a RLS para o Realtime enxergar — trocaria um custo\n-- de disco por um vazamento: as mensagens de uma casa chegariam ao navegador\n-- de outra. Entre pagar WAL e vazar conversa, paga-se o WAL.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_conversation_messages REPLICA IDENTITY FULL;\n\n-- Garante a publicação também — uma tabela com identidade cheia e fora da\n-- publicação continua sem emitir nada, e o sintoma seria idêntico.\nDO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_publication_tables\n WHERE pubname = 'supabase_realtime'\n AND schemaname = 'public'\n AND tablename = 'plg_conversation_messages'\n ) THEN\n EXECUTE 'ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_conversation_messages';\n END IF;\nEND $$;\n`\n\nexport const MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA = `-- ---------------------------------------------------------------------------\n-- 010_a_chegada_e_anunciada_por_conta.sql\n--\n-- ── por que \\`postgres_changes\\` nunca funcionou aqui ─────────────────────\n--\n-- O vigia assinava a TABELA de mensagens e recebia \\`SUBSCRIBED\\` do servidor.\n-- Nenhum evento chegava. Nenhum erro, nenhum aviso — a assinatura dizia que\n-- estava tudo bem.\n--\n-- O Realtime aplica a política de leitura a cada evento antes de entregá-lo. A\n-- política desta base resolve o tenant por, nesta ordem:\n--\n-- ① a claim \\`tenant_id\\` do JWT\n-- ② o CABEÇALHO da requisição (\\`app.requested_tenant()\\`)\n-- ③ a filiação única, quando a pessoa só tem uma\n--\n-- O Realtime manda o JWT e NÃO manda cabeçalho. E o JWT desta casa não carrega\n-- \\`tenant_id\\` — a conta ativa é escolhida por cabeçalho, porque a mesma pessoa\n-- atende sete contas. Sobra ③, que devolve nulo para quem tem mais de uma.\n--\n-- Resultado: a política recusa, o evento é descartado em silêncio, e o defeito\n-- parece \"o realtime não funciona\" quando na verdade é \"o realtime não sabe em\n-- que conta você está\".\n--\n-- ── a saída: anunciar, em vez de espiar a tabela ────────────────────────\n--\n-- Broadcast não passa pela RLS da tabela: o servidor entrega a quem está no\n-- TÓPICO. Então o tópico carrega a conta no nome — \\`conversas:<tenant>\\` — e\n-- quem escuta já teve de saber em que conta está para montar o nome.\n--\n-- Isso inverte a dependência de forma saudável: em vez de o banco adivinhar o\n-- contexto do navegador, o navegador declara o contexto que já tem.\n--\n-- ── o que vai no anúncio ────────────────────────────────────────────────\n--\n-- O MÍNIMO para decidir: id da conversa, canal, e um trecho. Não a mensagem\n-- inteira — um broadcast é entregue a todos do tópico, e mandar o corpo\n-- completo por ali seria espalhar conversa por um caminho que não é o que\n-- guarda conversa. Quem quiser o texto lê a tabela, com RLS, como sempre.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_nome text;\nBEGIN\n -- Só o que CHEGA. A resposta que o operador manda também é um INSERT, e\n -- anunciá-la faria o sistema apitar para quem acabou de digitar.\n IF NEW.direction IS DISTINCT FROM 'inbound' THEN\n RETURN NEW;\n END IF;\n\n SELECT c.contact_name INTO v_nome\n FROM public.plg_conversations c\n WHERE c.id = NEW.conversation_id;\n\n PERFORM realtime.send(\n jsonb_build_object(\n 'conversationId', NEW.conversation_id,\n 'channel', NEW.channel,\n 'contactName', coalesce(v_nome, ''),\n -- Um trecho, não o corpo: o suficiente para a notificação do sistema\n -- dizer do que se trata sem espalhar a conversa pelo broadcast.\n 'preview', left(coalesce(NEW.body, ''), 120),\n 'at', NEW.at\n ),\n 'inbound',\n 'conversas:' || NEW.tenant_id::text,\n -- Privado: exige que quem assina esteja autenticado. Público deixaria\n -- qualquer um com o nome do tópico ouvir a chegada.\n true\n );\n RETURN NEW;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.plg_conversations_anunciar_chegada() FROM public, anon, authenticated;\n\nDROP TRIGGER IF EXISTS plg_conversations_anunciar_chegada ON public.plg_conversation_messages;\nCREATE TRIGGER plg_conversations_anunciar_chegada\n AFTER INSERT ON public.plg_conversation_messages\n FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_anunciar_chegada();\n\nCOMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS\n 'Anuncia a chegada no tópico \\`conversas:<tenant>\\`. Broadcast e não \\`postgres_changes\\` porque a RLS da tabela resolve o tenant por cabeçalho, e o Realtime não manda cabeçalho (010).';\n`\n\nexport const MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO = `-- ---------------------------------------------------------------------------\n-- 011_o_anuncio_e_uma_campainha_nao_um_recado.sql\n--\n-- ── por que a 010 não podia ficar como estava ───────────────────────────\n--\n-- A 010 anunciava num tópico PRIVADO e mandava, no anúncio, o nome do contato e\n-- um trecho da mensagem. Tópico privado exige política em \\`realtime.messages\\` —\n-- e essa tabela é do Supabase, dona \\`supabase_realtime_admin\\`, um papel que\n-- esta conexão não pode assumir:\n--\n-- ERROR: 42501: permission denied to set role \"supabase_realtime_admin\"\n--\n-- Sem a política, o servidor recusa a assinatura e nada chega. Com o tópico\n-- público, qualquer um que soubesse o nome do tópico leria o trecho da\n-- conversa — o que não se faz.\n--\n-- ── a saída: separar o AVISO do CONTEÚDO ────────────────────────────────\n--\n-- O anúncio passa a ser uma campainha: diz que chegou algo e em que conversa,\n-- e mais nada. Nome e texto o navegador busca pela via de sempre — a tabela,\n-- com RLS, onde o cabeçalho da conta existe e a política funciona.\n--\n-- O tópico continua carregando o uuid da conta no nome, que é o que separa uma\n-- casa da outra. Ele não é adivinhável, e mesmo que fosse, o que se ouviria é\n-- \"chegou mensagem\", sem nome nem texto. O sigilo fica onde sempre esteve: na\n-- leitura da tabela.\n--\n-- É uma troca deliberada de superfície: em vez de tentar proteger um caminho\n-- novo, não se põe nada sensível nele.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nBEGIN\n -- Só o que CHEGA. A resposta que o operador manda também é um INSERT, e\n -- anunciá-la faria o sistema apitar para quem acabou de digitar.\n IF NEW.direction IS DISTINCT FROM 'inbound' THEN\n RETURN NEW;\n END IF;\n\n PERFORM realtime.send(\n -- SÓ o id da conversa. Nem nome, nem trecho, nem corpo: quem ouve vai\n -- buscar isso na tabela, com a política de leitura no meio do caminho.\n -- Um uuid sozinho não conta nada a quem não pode ler a conversa.\n jsonb_build_object('conversationId', NEW.conversation_id),\n 'inbound',\n 'conversas:' || NEW.tenant_id::text,\n -- PÚBLICO, e por isso o payload é vazio de conteúdo. Privado exigiria uma\n -- política numa tabela que não é nossa.\n false\n );\n RETURN NEW;\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS\n 'Toca a campainha no tópico \\`conversas:<tenant>\\` — só o id da conversa. O conteúdo é lido da tabela, com RLS (011).';\n`\n\nexport const MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE = `-- ---------------------------------------------------------------------------\n-- 012_a_rich_message_keeps_its_shape.sql\n--\n-- ── what was lost, and how ──────────────────────────────────────────────\n--\n-- \\`plg_conversation_messages\\` has exactly one content column: \\`body text\\`. A\n-- WhatsApp message that is not text therefore arrives flattened to whatever\n-- string the ingest could make of it — and when the ingest could make none, to\n-- the empty string, which renders as an empty bubble.\n--\n-- On 2026-09-22 a lead sent his finance director's contact card. It stored as\n-- \\`body = ''\\`. He then sent a screenshot of the same card; that stored as\n-- \\`body = ''\\` too. The fact the conversation existed to obtain — a name and a\n-- phone number — was in the payload both times and in the row neither time.\n--\n-- Recovering it meant going back to the Evolution instance and reading the\n-- provider's own copy. That worked, but it is not a recovery path: Evolution\n-- prunes, the instance is local-only, and a row that needs a second system to\n-- be legible is not a record.\n--\n-- ── the two columns ─────────────────────────────────────────────────────\n--\n-- \\`message_type\\` names what the provider sent — text, contact, image, audio,\n-- document, sticker, location, reaction, unknown. The inbox needs it to choose\n-- a bubble, and \\`unknown\\` is the one that makes the NEXT gap visible in a query\n-- instead of in a complaint.\n--\n-- \\`payload\\` keeps the part that does not survive flattening: the vCard's parsed\n-- name and numbers, a media mimetype and size, a location's coordinates. Not\n-- the whole provider envelope — that carries decryption keys and a base64\n-- thumbnail, neither of which belongs in a row that every member of the tenant\n-- can read.\n--\n-- Deliberately NOT stored here: delivery status. It changes after the fact —\n-- sent, delivered, read — and \\`conversations-provider\\`'s \\`message_detail\\` reads\n-- it live for that reason. \\`payload\\` is the shape of the message, which does\n-- not change once it arrives.\n--\n-- ── why both are nullable, and nothing is backfilled ────────────────────\n--\n-- Every row written before this migration has neither, and no default can\n-- invent them: \\`message_type\\` would have to guess, and guessing 'text' for the\n-- three empty bubbles already in the table would assert something false about\n-- exactly the rows this exists to fix. A reader that treats NULL as \"written\n-- before 012\" is correct; a reader that treats '' as text is the bug.\n--\n-- Table-level \\`GRANT ALL ... TO authenticated\\` already covers new columns, and\n-- the four tenant policies are row-scoped, so neither needs restating.\n--\n-- Idempotent and replay-safe.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_conversation_messages\n ADD COLUMN IF NOT EXISTS message_type text,\n ADD COLUMN IF NOT EXISTS payload jsonb;\n\nCOMMENT ON COLUMN public.plg_conversation_messages.message_type IS\n 'What the provider sent: text, contact, image, video, audio, document, sticker, location, reaction, unknown. NULL means the row predates 012 (#504).';\n\nCOMMENT ON COLUMN public.plg_conversation_messages.payload IS\n 'The structured remainder that \\`body\\` cannot hold — parsed vCards, media metadata, coordinates. Never credentials, thumbnails or decryption keys; never delivery status, which is read live (#504).';\n`\n\nexport const MIGRATION_013_A_MIDIA_FICA_CONOSCO = `-- ---------------------------------------------------------------------------\n-- 013_a_midia_fica_conosco.sql\n--\n-- ── o que a 012 resolveu, e o que ela não resolveu ──────────────────────\n--\n-- A 012 fez a mensagem que não é texto parar de chegar como bolha vazia:\n-- \\`message_type\\` diz que é imagem, \\`payload\\` guarda mimetype, tamanho e\n-- duração. Funcionou — um áudio de 23 segundos entrou classificado como\n-- \\`audio\\`, \\`ptt: true\\`, \\`audio/ogg; codecs=opus\\`, 55.825 bytes.\n--\n-- E a tela mostra \\`[áudio]\\`. Porque saber QUE existe um arquivo não é ter o\n-- arquivo. Nada no banco aponta para bytes nenhuns: o WhatsApp entrega mídia\n-- cifrada num CDN próprio, e quem decifra é o provedor.\n--\n-- ── por que uma coluna, e não um campo no payload ───────────────────────\n--\n-- A 012 escreveu que \\`payload\\` é \"a forma da mensagem, que não muda depois que\n-- ela chega\". Onde o arquivo foi parar é decidido DEPOIS — no upload, que pode\n-- falhar, ser refeito, ou ainda não ter acontecido. Enfiar isso no \\`payload\\`\n-- contradiz a única frase que diz quando \\`payload\\` pode ser lido sem medo.\n--\n-- Em coluna, a pergunta que importa vira consulta:\n--\n-- SELECT count(*) FROM plg_conversation_messages\n-- WHERE message_type IN ('image','video','audio','document','sticker')\n-- AND media_path IS NULL;\n--\n-- \"quanta mídia eu recebi e não guardei\" — que num campo de JSON seria uma\n-- varredura e aqui é um índice.\n--\n-- ── por que guardar, em vez de buscar no provedor quando alguém abrir ───\n--\n-- A Evolution decifra sob demanda, e seria mais barato não copiar nada. Mas ela\n-- é local-only, poda o que é antigo, e a instância roda numa máquina que nem\n-- sempre está de pé. Essa era exatamente a objeção da 012 contra depender dela\n-- para ler um vCard: uma linha que precisa de um segundo sistema para ser\n-- legível não é registro. Vale igual para os bytes.\n--\n-- O destino é o balde \\`attachments\\` — privado, 25 MB, já existente e já em uso.\n-- Uma foto que um lead mandou no WhatsApp não pode ser pública, e um balde novo\n-- seria mais uma lista de mimetypes para alguém manter desatualizada.\n--\n-- ── nulo é um estado legítimo, não uma falha ────────────────────────────\n--\n-- \\`media_path\\` é NULL em três situações diferentes e todas normais: a mensagem\n-- é texto; a linha é anterior a esta migração; ou o upload ainda não aconteceu.\n-- Quem lê distingue pela combinação com \\`message_type\\`, que é o que torna a\n-- consulta acima possível. Um default inventaria um caminho para arquivo que\n-- não existe.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.plg_conversation_messages\n ADD COLUMN IF NOT EXISTS media_path text;\n\nCOMMENT ON COLUMN public.plg_conversation_messages.media_path IS\n 'Onde os bytes ficaram no balde \\`attachments\\`, ou NULL: texto, linha anterior à 013, ou upload que ainda não aconteceu. Distingue-se pela combinação com \\`message_type\\` (013).';\n\n-- Parcial: só a mídia sem arquivo interessa, e é a minoria. O índice fica do\n-- tamanho do problema em vez do tamanho da tabela.\nCREATE INDEX IF NOT EXISTS plg_conversation_messages_midia_sem_arquivo\n ON public.plg_conversation_messages (tenant_id, at DESC)\n WHERE media_path IS NULL\n AND message_type IN ('image', 'video', 'audio', 'document', 'sticker');\n`\n\nexport const MIGRATION_014_CLAIMING_TARGETS_IS_SERVER_ONLY = `-- 014_claiming_targets_is_server_only.sql\n--\n-- 003 revoked \\`conversations_claim_targets\\` from PUBLIC and anon but not from\n-- \\`authenticated\\`, which the pool's default privileges grant on every new\n-- function in public. Live, any signed-in user of any tenant could claim (and\n-- so burn) another tenant's campaign targets by uuid — SECURITY DEFINER, no\n-- caller check. The only caller is the conversations-provider edge function,\n-- which uses the service-role key.\n\nREVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;\n`\n\nexport const MIGRATION_015_THE_SERVICE_DOOR_FOR_MESSAGING = `-- ============================================================================\n-- 015_the_service_door_for_messaging — the WhatsApp edge functions move to the\n-- integration service (ADR 0003 phase 2, wave 2).\n--\n-- Two kinds of table, two kinds of access:\n--\n-- * tenant tables a member already writes (conversations, messages,\n-- campaigns, templates): the service writes them AS the store's owner or\n-- as the verified caller (core migrations 210/212), under RLS. Nothing\n-- here.\n-- * service tables only a machine writes, which service_role wrote until\n-- now: provider credentials, channels, opt-outs, webhook events, provider\n-- templates, payment requests. fayz_integration gets exactly that, table\n-- by table, with an explicit policy. Panels keep their SELECT policies.\n--\n-- And the three server-only functions the webhooks call get EXECUTE for the\n-- service role (they were service_role only, 014).\n--\n-- Guarded: the role is an operator step. Replay-safe.\n-- ============================================================================\n\nDO $$\nDECLARE\n t text;\n f text;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN\n RETURN;\n END IF;\n\n FOREACH t IN ARRAY ARRAY[\n 'plg_conversations_providers',\n 'plg_conversations_channels',\n 'plg_conversations_optouts',\n 'plg_conversations_webhook_events',\n 'plg_automations_provider_templates',\n 'plg_conversations_payment_requests'\n ] LOOP\n IF to_regclass('public.' || t) IS NULL THEN CONTINUE; END IF;\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO fayz_integration', t);\n EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_service', t);\n EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO fayz_integration USING (true) WITH CHECK (true)', t || '_service', t);\n END LOOP;\n\n FOREACH f IN ARRAY ARRAY[\n 'public.conversations_claim_targets(uuid, integer)',\n 'public.plg_booking_apply_channel_reply(uuid, text, uuid)',\n 'public.plg_conversations_match_by_phone(text, text, text, uuid)'\n ] LOOP\n IF to_regprocedure(f) IS NOT NULL THEN\n EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO fayz_integration', f);\n END IF;\n END LOOP;\nEND $$;\n`\n\nexport const MIGRATION_016_A_DELIVERY_RECEIPT_FINDS_ITS_TENANT = `-- ============================================================================\n-- 016_a_delivery_receipt_finds_its_tenant — the Tyxter webhook moves to the\n-- integration service (ADR 0003 phase 2).\n--\n-- A delivery receipt names only the provider's message id. The service writes\n-- the inbox AS the store's owner (core 212), so it must learn the store first:\n-- this answers the tenant of the message that id was sent as, and nothing\n-- else. SECURITY DEFINER, service role only.\n--\n-- Replay-safe: CREATE OR REPLACE; guarded grants.\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION public.conversations_message_tenant(p_provider_message_id text) RETURNS uuid\nLANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''\nAS $function$\n SELECT m.tenant_id\n FROM public.plg_conversation_messages m\n WHERE m.provider_message_id = p_provider_message_id\n ORDER BY m.at DESC NULLS LAST\n LIMIT 1;\n$function$;\n\nDO $$\nBEGIN\n REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM PUBLIC;\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM anon; END IF;\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM authenticated; END IF;\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN GRANT EXECUTE ON FUNCTION public.conversations_message_tenant(text) TO service_role; END IF;\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN GRANT EXECUTE ON FUNCTION public.conversations_message_tenant(text) TO fayz_integration; END IF;\nEND $$;\n`\n\nexport const MIGRATIONS: Array<{ id: string; sql: string }> = [\n { id: \"000_baseline\", sql: MIGRATION_000_BASELINE },\n { id: \"001_a_caixa_sabe_quem_falou_por_ultimo\", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },\n { id: \"002_a_casa_escolhe_por_onde_o_whatsapp_sai\", sql: MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI },\n { id: \"003_a_campanha_sai_no_ritmo_que_o_numero_aguenta\", sql: MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA },\n { id: \"004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever\", sql: MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER },\n { id: \"005_a_conversa_conta_ao_funil_que_houve_contato\", sql: MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO },\n { id: \"006_a_caixa_avisa_que_chegou\", sql: MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU },\n { id: \"007_a_conversa_diz_de_quem_e_o_lead\", sql: MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD },\n { id: \"008_dar_por_vista_e_uma_decisao\", sql: MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO },\n { id: \"009_a_mensagem_inteira_atravessa_o_realtime\", sql: MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME },\n { id: \"010_a_chegada_e_anunciada_por_conta\", sql: MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA },\n { id: \"011_o_anuncio_e_uma_campainha_nao_um_recado\", sql: MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO },\n { id: \"012_a_rich_message_keeps_its_shape\", sql: MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE },\n { id: \"013_a_midia_fica_conosco\", sql: MIGRATION_013_A_MIDIA_FICA_CONOSCO },\n { id: \"014_claiming_targets_is_server_only\", sql: MIGRATION_014_CLAIMING_TARGETS_IS_SERVER_ONLY },\n { id: \"015_the_service_door_for_messaging\", sql: MIGRATION_015_THE_SERVICE_DOOR_FOR_MESSAGING },\n { id: \"016_a_delivery_receipt_finds_its_tenant\", sql: MIGRATION_016_A_DELIVERY_RECEIPT_FINDS_ITS_TENANT },\n]\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationList.d.ts","sourceRoot":"","sources":["../../src/views/ConversationList.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"ConversationList.d.ts","sourceRoot":"","sources":["../../src/views/ConversationList.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAazB,wBAAgB,gBAAgB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBAiMrE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationsPage.d.ts","sourceRoot":"","sources":["../../src/views/ConversationsPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC/C,OAAO,EAAgC,KAAK,2BAA2B,EAAE,MAAM,YAAY,CAAA;AAC3F,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"ConversationsPage.d.ts","sourceRoot":"","sources":["../../src/views/ConversationsPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC/C,OAAO,EAAgC,KAAK,2BAA2B,EAAE,MAAM,YAAY,CAAA;AAC3F,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AAiCpD,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IACnD,KAAK,EAAE,QAAQ,CAAC,oBAAoB,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,2BAA2B,CAAA;CACrC,qBAgCA"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The foot of the inbox list: a green «Conectar WhatsApp» when no number is
|
|
3
|
+
* connected, otherwise «WhatsApp conectado · Gerenciar». The connector lives
|
|
4
|
+
* in Configurações → Conversas → Conectores, two levels away from where the
|
|
5
|
+
* person notices the inbox is empty; this is the short way there.
|
|
6
|
+
*/
|
|
7
|
+
export declare function WhatsAppConnectionFooter(): import("react").JSX.Element;
|
|
8
|
+
//# sourceMappingURL=WhatsAppConnectionFooter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WhatsAppConnectionFooter.d.ts","sourceRoot":"","sources":["../../src/views/WhatsAppConnectionFooter.tsx"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,wBAAgB,wBAAwB,gCAsBvC"}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"status": "preview",
|
|
5
5
|
"dependencies": []
|
|
6
6
|
},
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "0.24.0",
|
|
8
8
|
"description": "[experimental] Fayz SDK — unified conversations / omni-channel inbox plugin",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"sideEffects": false,
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
"import": "./dist/index.js"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
|
+
"imports": {
|
|
21
|
+
"#migrations/*": {
|
|
22
|
+
"browser": "./dist/migrations/*.browser.js",
|
|
23
|
+
"default": "./dist/migrations/*.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
20
26
|
"files": [
|
|
21
27
|
"dist"
|
|
22
28
|
],
|
|
@@ -27,9 +33,9 @@
|
|
|
27
33
|
"lucide-react": ">=0.400.0 <1.0.0"
|
|
28
34
|
},
|
|
29
35
|
"dependencies": {
|
|
30
|
-
"@fayz-ai/core": "^0.
|
|
31
|
-
"@fayz-ai/ui": "^0.
|
|
32
|
-
"@fayz-ai/admin": "^0.
|
|
36
|
+
"@fayz-ai/core": "^0.66.0",
|
|
37
|
+
"@fayz-ai/ui": "^0.66.0",
|
|
38
|
+
"@fayz-ai/admin": "^0.66.0"
|
|
33
39
|
},
|
|
34
40
|
"devDependencies": {
|
|
35
41
|
"@types/react": "^18.3.0",
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
import type { StoreApi } from 'zustand/vanilla';
|
|
3
|
-
import { type ResolvedConversationsConfig } from '../context';
|
|
4
|
-
import type { ConversationsUIState } from '../store';
|
|
5
|
-
/**
|
|
6
|
-
* Conversas in the right column, beside the assistant: answering a customer is
|
|
7
|
-
* work that has to stay open while the person checks an order or the agenda.
|
|
8
|
-
* The column is narrow, so list and thread take turns; dragged wide, they sit
|
|
9
|
-
* side by side.
|
|
10
|
-
*/
|
|
11
|
-
export declare function ConversationsRailPanel({ store, config }: {
|
|
12
|
-
store: StoreApi<ConversationsUIState>;
|
|
13
|
-
config?: ResolvedConversationsConfig;
|
|
14
|
-
}): React.JSX.Element;
|
|
15
|
-
//# sourceMappingURL=ConversationsRailPanel.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationsRailPanel.d.ts","sourceRoot":"","sources":["../../src/components/ConversationsRailPanel.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAI/C,OAAO,EAAgC,KAAK,2BAA2B,EAAE,MAAM,YAAY,CAAA;AAC3F,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AAQpD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IACxD,KAAK,EAAE,QAAQ,CAAC,oBAAoB,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,2BAA2B,CAAA;CACrC,qBA2DA"}
|