@fayz-ai/plugin-agenda 0.27.10 → 0.27.12

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.
Files changed (45) hide show
  1. package/dist/AgendaGeneralSettings-YBI43RB6.js +96 -0
  2. package/dist/AgendaGeneralSettings-YBI43RB6.js.map +1 -0
  3. package/dist/AgendaMessageTemplates-46ZLTFLM.js +31 -0
  4. package/dist/AgendaMessageTemplates-46ZLTFLM.js.map +1 -0
  5. package/dist/AgendaPage-43OXQDZE.js +3174 -0
  6. package/dist/AgendaPage-43OXQDZE.js.map +1 -0
  7. package/dist/CheckinPage-SGWK2TQZ.js +904 -0
  8. package/dist/CheckinPage-SGWK2TQZ.js.map +1 -0
  9. package/dist/ConfirmationsView-DZJSYBXA.js +361 -0
  10. package/dist/ConfirmationsView-DZJSYBXA.js.map +1 -0
  11. package/dist/MyClientsTodayWidget-ZGTXJJKN.js +127 -0
  12. package/dist/MyClientsTodayWidget-ZGTXJJKN.js.map +1 -0
  13. package/dist/PersonAttendanceBlock-PHZVHFQ4.js +105 -0
  14. package/dist/PersonAttendanceBlock-PHZVHFQ4.js.map +1 -0
  15. package/dist/PersonAttendancesWidget-3NTWBGCU.js +1278 -0
  16. package/dist/PersonAttendancesWidget-3NTWBGCU.js.map +1 -0
  17. package/dist/PersonServicesWidget-4RJGKX3K.js +80 -0
  18. package/dist/PersonServicesWidget-4RJGKX3K.js.map +1 -0
  19. package/dist/chunk-37INOUNW.js +20 -0
  20. package/dist/chunk-37INOUNW.js.map +1 -0
  21. package/dist/chunk-A2VD5DVN.js +12 -0
  22. package/dist/chunk-A2VD5DVN.js.map +1 -0
  23. package/dist/chunk-HGJI2JR6.js +169 -0
  24. package/dist/chunk-HGJI2JR6.js.map +1 -0
  25. package/dist/chunk-VFQPXKXP.js +11 -0
  26. package/dist/chunk-VFQPXKXP.js.map +1 -0
  27. package/dist/chunk-WNANVT4F.js +2386 -0
  28. package/dist/chunk-WNANVT4F.js.map +1 -0
  29. package/dist/chunk-X7PKHXT6.js +18 -0
  30. package/dist/chunk-X7PKHXT6.js.map +1 -0
  31. package/dist/chunk-XTNSK4RJ.js +12 -0
  32. package/dist/chunk-XTNSK4RJ.js.map +1 -0
  33. package/dist/components/PersonAttendanceBlock.d.ts.map +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2530 -19553
  36. package/dist/index.js.map +1 -1
  37. package/dist/migrations/integrations-google-calendar.browser.js +15 -0
  38. package/dist/migrations/integrations-google-calendar.browser.js.map +1 -0
  39. package/dist/migrations/integrations-google-calendar.js +1992 -0
  40. package/dist/migrations/integrations-google-calendar.js.map +1 -0
  41. package/dist/migrations/main.browser.js +29 -0
  42. package/dist/migrations/main.browser.js.map +1 -0
  43. package/dist/migrations/main.js +6440 -0
  44. package/dist/migrations/main.js.map +1 -0
  45. package/package.json +13 -7
@@ -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;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;;AAAA;;AAAA;;AAAA;;AAAA;;AAAA;;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAk2C/B,IAAM,uDAAA,GAA0D,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;AAkDhE,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,iFAAA;AAuDrD,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;AA2B7D,IAAM,8CAAA,GAAiD,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;AA8CvD,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;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmD1D,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;AAqH7D,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;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AA6OrD,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;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsX/C,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;AAAA;AAAA;AAgErD,IAAM,qCAAA,GAAwC,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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAs1B9C,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;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAiFlD,IAAM,+CAAA,GAAkD,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;AA4FxD,IAAM,kDAAA,GAAqD,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;AAsK3D,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;AA4DtD,IAAM,wDAAA,GAA2D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAcjE,IAAM,6BAAA,GAAgC,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;AAqVtC,IAAM,qCAAA,GAAwC,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;AA0J9C,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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAsNlD,IAAM,0CAAA,GAA6C,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;AA2OnD,IAAM,2DAAA,GAA8D,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;AA0DpE,IAAM,4BAAA,GAA+B,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;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAizBrC,IAAM,uDAAA,GAA0D,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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAo7BhE,IAAM,UAAA,GAAiD;AAAA,EAC5D,EAAE,EAAA,EAAI,cAAA,EAAgB,GAAA,EAAK,sBAAA,EAAuB;AAAA,EAClD,EAAE,EAAA,EAAI,+CAAA,EAAiD,GAAA,EAAK,uDAAA,EAAwD;AAAA,EACpH,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,4CAAA,EAA8C,GAAA,EAAK,oDAAA,EAAqD;AAAA,EAC9G,EAAE,EAAA,EAAI,sCAAA,EAAwC,GAAA,EAAK,8CAAA,EAA+C;AAAA,EAClG,EAAE,EAAA,EAAI,yCAAA,EAA2C,GAAA,EAAK,iDAAA,EAAkD;AAAA,EACxG,EAAE,EAAA,EAAI,4CAAA,EAA8C,GAAA,EAAK,oDAAA,EAAqD;AAAA,EAC9G,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,8BAAA,EAAgC,GAAA,EAAK,sCAAA,EAAuC;AAAA,EAClF,EAAE,EAAA,EAAI,oCAAA,EAAsC,GAAA,EAAK,4CAAA,EAA6C;AAAA,EAC9F,EAAE,EAAA,EAAI,6BAAA,EAA+B,GAAA,EAAK,qCAAA,EAAsC;AAAA,EAChF,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,uCAAA,EAAyC,GAAA,EAAK,+CAAA,EAAgD;AAAA,EACpG,EAAE,EAAA,EAAI,0CAAA,EAA4C,GAAA,EAAK,kDAAA,EAAmD;AAAA,EAC1G,EAAE,EAAA,EAAI,qCAAA,EAAuC,GAAA,EAAK,6CAAA,EAA8C;AAAA,EAChG,EAAE,EAAA,EAAI,gDAAA,EAAkD,GAAA,EAAK,wDAAA,EAAyD;AAAA,EACtH,EAAE,EAAA,EAAI,qBAAA,EAAuB,GAAA,EAAK,6BAAA,EAA8B;AAAA,EAChE,EAAE,EAAA,EAAI,6BAAA,EAA+B,GAAA,EAAK,qCAAA,EAAsC;AAAA,EAChF,EAAE,EAAA,EAAI,iCAAA,EAAmC,GAAA,EAAK,yCAAA,EAA0C;AAAA,EACxF,EAAE,EAAA,EAAI,kCAAA,EAAoC,GAAA,EAAK,0CAAA,EAA2C;AAAA,EAC1F,EAAE,EAAA,EAAI,mDAAA,EAAqD,GAAA,EAAK,2DAAA,EAA4D;AAAA,EAC5H,EAAE,EAAA,EAAI,oBAAA,EAAsB,GAAA,EAAK,4BAAA,EAA6B;AAAA,EAC9D,EAAE,EAAA,EAAI,+CAAA,EAAiD,GAAA,EAAK,uDAAA;AAC9D","file":"main.js","sourcesContent":["// AUTO-GENERATED from 000_baseline.sql, 001_the_connector_definers_keep_a_pinned_path.sql, 002_the_appointment_names_the_unit.sql, 003_the_client_arrives_before_being_served.sql, 004_the_appointment_carries_its_unit.sql, 005_the_queue_knows_how_long_she_waited.sql, 006_the_agenda_stops_pricing_every_invoice.sql, 007_o_cascade_levou_a_agenda_junto.sql, 008_a_agenda_do_v1_atravessa.sql, 009_o_executor_se_acha_pelo_pedido.sql, 010_a_grade_do_v1_atravessa.sql, 011_a_sala_entra_no_compromisso.sql, 012_a_sala_sem_nome_tambem_e_uma_sala.sql, 013_o_selo_do_atendimento_tem_onde_morar.sql, 014_a_configuracao_nao_e_uma_tabela.sql, 015_o_feriado_tambem_tem_observacao_e_etiqueta.sql, 016_a_vez_e_um_fato.sql, 017_a_equipe_do_dia_aparece.sql, 018_o_agendamento_que_se_repete.sql, 019_a_serie_nasce_numa_viagem_so.sql, 020_a_lista_da_vez_se_liga_no_cadastro_da_unidade.sql, 021_a_vez_por_area.sql, 022_a_chave_da_fila_e_configuracao_da_unidade.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-agenda/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 23 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-- ── function ────────────────────────────────────────────────────────────\n\nCREATE FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) RETURNS void\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n IF EXISTS (SELECT 1 FROM app.resource_grants g\n WHERE g.tenant_id = p_tenant_id\n AND g.resource_type = 'catalog.product'\n AND g.resource_id = p_service_id\n AND g.relation = 'available_in') THEN\n RAISE EXCEPTION 'este serviço não está disponível para agendamento online'\n USING ERRCODE = '42501',\n HINT = 'The service is released to specific units; the public booking path is tenant-wide and cannot honour a unit release (#201).';\n END IF;\nEND $$;\n\nCREATE FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean DEFAULT false) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $_$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_appt public.appointments%ROWTYPE;\n v_money integer := 0;\nBEGIN\n SELECT * INTO v_appt FROM public.appointments a WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;\n IF v_appt.id IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_cancel: appointment % not found', p_appointment_id USING ERRCODE = 'P0002';\n END IF;\n IF NOT (select app.has_permission('agenda.delete', v_appt.unit_id)) THEN\n RAISE EXCEPTION 'agenda_booking_cancel: agenda.delete is required' USING ERRCODE = '42501';\n END IF;\n\n -- Money that exists is never deleted. A booking that raised a Título is\n -- CANCELLED, both sides, in one transaction.\n IF v_appt.order_id IS NOT NULL AND to_regclass('public.plg_financial_invoices') IS NOT NULL THEN\n EXECUTE 'SELECT count(*)::int FROM public.plg_financial_invoices WHERE order_id = $1 AND cancelled_at IS NULL'\n INTO v_money USING v_appt.order_id;\n END IF;\n\n IF v_money > 0 OR NOT p_hard THEN\n UPDATE public.appointments SET status = 'cancelled' WHERE id = p_appointment_id;\n IF v_appt.order_id IS NOT NULL THEN\n UPDATE public.orders SET status = 'cancelled' WHERE id = v_appt.order_id AND status <> 'completed';\n END IF;\n RETURN jsonb_build_object('cancelled', true, 'deleted', false, 'order_id', v_appt.order_id);\n END IF;\n\n DELETE FROM public.appointments WHERE id = p_appointment_id;\n IF v_appt.order_id IS NOT NULL THEN\n DELETE FROM public.orders WHERE id = v_appt.order_id;\n END IF;\n RETURN jsonb_build_object('cancelled', true, 'deleted', true, 'order_id', v_appt.order_id);\nEND $_$;\n\nCREATE FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_lines jsonb DEFAULT '[]'::jsonb, p_party_id uuid DEFAULT NULL::uuid, p_assignee_id uuid DEFAULT NULL::uuid, p_unit_id uuid DEFAULT NULL::uuid, p_location_id uuid DEFAULT NULL::uuid, p_kind text DEFAULT 'appointment'::text, p_notes text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_order uuid;\n v_booking uuid;\n v_ends timestamptz;\n v_minutes integer;\n v_line jsonb;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_create: no active tenant' USING ERRCODE = '42501';\n END IF;\n IF NOT (select app.has_permission('agenda.create', p_unit_id)) THEN\n RAISE EXCEPTION 'agenda_booking_create: agenda.create is required' USING ERRCODE = '42501';\n END IF;\n\n IF p_idempotency_key IS NOT NULL THEN\n SELECT a.id, a.order_id INTO v_booking, v_order FROM public.appointments a\n WHERE a.tenant_id = v_tenant AND a.metadata ->> 'idempotencyKey' = p_idempotency_key\n LIMIT 1;\n IF v_booking IS NOT NULL THEN\n RETURN jsonb_build_object('appointment_id', v_booking, 'order_id', v_order, 'replayed', true);\n END IF;\n END IF;\n\n SELECT coalesce(sum(coalesce((l ->> 'duration_minutes')::integer, 0)), 0)\n INTO v_minutes FROM jsonb_array_elements(coalesce(p_lines, '[]'::jsonb)) l;\n v_ends := coalesce(p_ends_at, p_starts_at + (greatest(v_minutes, 30) || ' minutes')::interval);\n\n -- The core's door, not a direct write. One statement, and the lines come with\n -- it: there is no window in which the Venda exists without them.\n v_order := public.order_create(\n p_kind => p_kind,\n p_lines => coalesce(p_lines, '[]'::jsonb),\n p_party_id => p_party_id,\n p_unit_id => p_unit_id,\n p_status => 'scheduled',\n p_assignee_id => p_assignee_id,\n p_notes => p_notes,\n p_metadata => coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'agenda'),\n p_idempotency_key => p_idempotency_key);\n\n INSERT INTO public.appointments (tenant_id, unit_id, kind, party_id, assignee_id, location_id,\n order_id, starts_at, ends_at, status, notes, metadata)\n VALUES (v_tenant, p_unit_id, p_kind, p_party_id, p_assignee_id, p_location_id,\n v_order, p_starts_at, v_ends, 'scheduled', p_notes,\n coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'agenda')\n || CASE WHEN p_idempotency_key IS NULL THEN '{}'::jsonb\n ELSE jsonb_build_object('idempotencyKey', p_idempotency_key) END)\n RETURNING id INTO v_booking;\n\n -- The agenda's own per-line columns (001 added them to the line table): who\n -- performs it and how long it takes.\n FOR v_line IN SELECT * FROM jsonb_array_elements(coalesce(p_lines, '[]'::jsonb)) LOOP\n UPDATE public.items oi\n SET duration_minutes = nullif(v_line ->> 'duration_minutes', '')::integer,\n assignee_id = coalesce(nullif(v_line ->> 'assignee_id', '')::uuid, p_assignee_id)\n WHERE oi.order_id = v_order\n AND oi.product_id IS NOT DISTINCT FROM nullif(v_line ->> 'service_id', '')::uuid\n AND oi.name = coalesce(nullif(btrim(coalesce(v_line ->> 'name', '')), ''), 'Item');\n END LOOP;\n\n RETURN jsonb_build_object('appointment_id', v_booking, 'order_id', v_order, 'replayed', false);\nEND $$;\n\nCREATE FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer DEFAULT 1, p_due_date date DEFAULT NULL::date) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_order uuid;\nBEGIN\n SELECT a.order_id INTO v_order FROM public.appointments a\n WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;\n IF v_order IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_invoice: appointment % has no Venda to bill', p_appointment_id\n USING ERRCODE = 'P0002',\n HINT = 'A booking created through agenda_booking_create always carries one; v_appointments_sem_order lists the rest.';\n END IF;\n\n IF to_regprocedure('public.fn_invoice_from_order(uuid, date, text, integer, text)') IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_invoice: the financial plugin is not installed' USING ERRCODE = '0A000';\n END IF;\n\n PERFORM public.fn_invoice_from_order(v_order, p_due_date, 'invoiced', greatest(coalesce(p_installments, 1), 1), 'credit');\n RETURN jsonb_build_object('order_id', v_order, 'appointment_id', p_appointment_id);\nEND $$;\n\nCREATE FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_ends_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_status text DEFAULT NULL::text, p_party_id uuid DEFAULT NULL::uuid, p_assignee_id uuid DEFAULT NULL::uuid, p_location_id uuid DEFAULT NULL::uuid, p_notes text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb, p_lines jsonb DEFAULT NULL::jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_appt public.appointments%ROWTYPE;\n v_line jsonb;\n v_n integer := 0;\n v_sub numeric := 0;\n v_qty numeric;\n v_price numeric;\nBEGIN\n SELECT * INTO v_appt FROM public.appointments a WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;\n IF v_appt.id IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_update: appointment % not found', p_appointment_id USING ERRCODE = 'P0002';\n END IF;\n IF NOT (select app.has_permission('agenda.edit', v_appt.unit_id)) THEN\n RAISE EXCEPTION 'agenda_booking_update: agenda.edit is required' USING ERRCODE = '42501';\n END IF;\n\n UPDATE public.appointments SET\n starts_at = coalesce(p_starts_at, starts_at),\n ends_at = coalesce(p_ends_at, ends_at),\n status = coalesce(p_status, status),\n party_id = coalesce(p_party_id, party_id),\n assignee_id = coalesce(p_assignee_id, assignee_id),\n location_id = coalesce(p_location_id, location_id),\n notes = coalesce(p_notes, notes),\n metadata = coalesce(metadata, '{}'::jsonb) || coalesce(p_metadata, '{}'::jsonb)\n WHERE id = p_appointment_id;\n\n IF v_appt.order_id IS NOT NULL THEN\n UPDATE public.orders SET\n party_id = coalesce(p_party_id, party_id),\n assignee_id = coalesce(p_assignee_id, assignee_id),\n location_id = coalesce(p_location_id, location_id),\n notes = coalesce(p_notes, notes)\n WHERE id = v_appt.order_id;\n\n IF p_lines IS NOT NULL THEN\n -- Replacing the lines and the header total in the SAME statement pair is\n -- the point: a total that disagrees with its lines is what two browser\n -- calls left behind whenever the second one did not land.\n DELETE FROM public.items WHERE order_id = v_appt.order_id;\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_n := v_n + 1;\n v_qty := coalesce((v_line ->> 'quantity')::numeric, 1);\n v_price := round(coalesce((v_line ->> 'unit_price')::numeric, 0), 2);\n INSERT INTO public.items (order_id, product_id, name, quantity, unit_price, total,\n sort_order, duration_minutes, assignee_id)\n VALUES (v_appt.order_id, nullif(v_line ->> 'service_id', '')::uuid,\n coalesce(nullif(btrim(coalesce(v_line ->> 'name', '')), ''), 'Item'),\n v_qty, v_price, round(v_qty * v_price, 2),\n coalesce((v_line ->> 'sort_order')::integer, v_n - 1),\n nullif(v_line ->> 'duration_minutes', '')::integer,\n coalesce(nullif(v_line ->> 'assignee_id', '')::uuid, p_assignee_id, v_appt.assignee_id));\n v_sub := v_sub + round(v_qty * v_price, 2);\n END LOOP;\n UPDATE public.orders SET subtotal = v_sub, total = v_sub WHERE id = v_appt.order_id;\n END IF;\n END IF;\n\n RETURN jsonb_build_object('appointment_id', p_appointment_id, 'order_id', v_appt.order_id);\nEND $$;\n\nCREATE FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid DEFAULT NULL::uuid) RETURNS TABLE(assignee_id uuid, win_start time without time zone, win_end time without time zone)\n LANGUAGE sql STABLE\n SET search_path TO 'public'\n AS $$\n WITH exc AS (\n -- Every specific-date row for the date, ACTIVE OR NOT: the inactive day-off\n -- sentinel must still suppress the weekly rows below.\n SELECT sc.assignee_id, sc.starts_at, sc.ends_at, sc.is_active\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.specific_date = p_date\n AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n )\n SELECT e.assignee_id, e.starts_at, e.ends_at\n FROM exc e\n WHERE e.is_active\n UNION ALL\n SELECT sc.assignee_id, sc.starts_at, sc.ends_at\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.is_active\n AND sc.specific_date IS NULL\n AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint\n AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n AND NOT EXISTS (SELECT 1 FROM exc e WHERE e.assignee_id = sc.assignee_id)\n$$;\n\nCREATE FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $_$\nDECLARE\n v_denial jsonb;\n v_used int;\n v_client_id uuid;\n v_service_id uuid;\n v_professional uuid;\n v_starts timestamptz;\n v_starts_raw text;\n v_notes text;\n v_service record;\n v_client record;\n v_ends timestamptz;\n v_tz text;\n v_order uuid;\n v_booking uuid;\nBEGIN\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM tenants t WHERE t.id = p_tenant_id;\n IF v_tz IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');\n END IF;\n\n -- ── payload ──────────────────────────────────────────────────────────────\n BEGIN\n v_client_id := (p_payload->>'client_id')::uuid;\n v_service_id := (p_payload->>'service_id')::uuid;\n v_professional := (p_payload->>'professional_id')::uuid;\n v_starts_raw := p_payload->>'starts_at';\n IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN\n v_starts := v_starts_raw::timestamptz; -- explicit offset\n ELSE\n v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local\n END IF;\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);\n END;\n v_notes := left(p_payload->>'notes', 500);\n IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');\n END IF;\n IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');\n END IF;\n\n -- ── authorization: role → plan → bookings_month cap ──────────────────────\n SELECT count(*) INTO v_used FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.created_at >= date_trunc('month', now());\n v_denial := agent_guard(p_tenant_id, p_actor_user_id,\n 'appointments', 'create', 'bookings_month', v_used, 1);\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n -- ── invariants ───────────────────────────────────────────────────────────\n SELECT p.id, p.name INTO v_client\n FROM people p\n WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');\n END IF;\n\n SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes\n INTO v_service\n FROM services s\n WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id\n AND s.is_active AND s.status = 'active';\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');\n END IF;\n\n v_ends := v_starts + make_interval(mins => v_service.duration_minutes);\n\n -- exception-aware windows (007); still prefer a professional who is FREE at\n -- the slot when the user did not name one\n SELECT w.assignee_id INTO v_professional\n FROM agenda_working_windows(\n p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w\n WHERE (v_starts AT TIME ZONE v_tz)::time >= w.win_start\n AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end\n AND NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = w.assignee_id\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < v_ends\n AND COALESCE(b.ends_at, b.starts_at) > v_starts\n )\n ORDER BY w.assignee_id\n LIMIT 1;\n IF v_professional IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);\n END IF;\n\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));\n IF EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = v_professional\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < v_ends\n AND COALESCE(b.ends_at, b.starts_at) > v_starts\n ) THEN\n RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');\n END IF;\n\n -- ── write + audit ────────────────────────────────────────────────────────\n INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)\n VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,\n v_service.price, v_service.price, v_notes,\n jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))\n RETURNING id INTO v_order;\n\n INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,\n starts_at, ends_at, status, notes, metadata)\n VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,\n v_starts, v_ends, 'scheduled', v_notes,\n jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))\n RETURNING id INTO v_booking;\n\n INSERT INTO items (order_id, service_id, name, quantity, unit_price, total,\n sort_order, duration_minutes, assignee_id)\n VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,\n 0, v_service.duration_minutes, v_professional);\n\n INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,\n jsonb_build_object('payload', p_payload, 'order_id', v_order));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_booking,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',\n 'archetype', 'schedule:appointment'),\n 'starts_at', v_starts,\n 'ends_at', v_ends,\n 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,\n 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),\n 'client_name', v_client.name,\n 'service_name', v_service.name,\n 'price', v_service.price,\n 'status', 'scheduled'\n )\n );\nEND;\n$_$;\n\nCREATE FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $_$\nDECLARE\n v_denial jsonb;\n v_id uuid;\n v_tz text;\n v_row record;\n v_starts_raw text;\n v_starts timestamptz;\n v_ends timestamptz;\n v_duration int;\n v_professional uuid;\n v_status text;\n v_notes text;\n v_wants_move boolean;\n v_wants_status boolean;\nBEGIN\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM tenants t WHERE t.id = p_tenant_id;\n IF v_tz IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');\n END IF;\n\n -- ── payload ──────────────────────────────────────────────────────────────\n BEGIN\n v_id := (p_payload->>'appointment_id')::uuid;\n v_professional := (p_payload->>'professional_id')::uuid;\n v_starts_raw := p_payload->>'starts_at';\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);\n END;\n v_status := nullif(trim(lower(p_payload->>'status')), '');\n v_notes := left(p_payload->>'notes', 500);\n v_wants_move := v_starts_raw IS NOT NULL OR v_professional IS NOT NULL;\n v_wants_status := v_status IS NOT NULL;\n\n IF v_id IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'appointment_id is required — search the agenda first and reuse the id of the appointment to change');\n END IF;\n IF NOT v_wants_move AND NOT v_wants_status AND v_notes IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'nothing to change: send starts_at, professional_id, status or notes');\n END IF;\n IF v_status IS NOT NULL\n AND v_status NOT IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show') THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'status must be one of scheduled, confirmed, in_progress, completed, cancelled, no_show');\n END IF;\n\n -- ── the appointment must exist, in THIS tenant ───────────────────────────\n SELECT b.id, b.starts_at, b.ends_at, b.assignee_id, b.status, b.party_id, b.order_id\n INTO v_row\n FROM appointments b\n WHERE b.id = v_id AND b.tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown appointment for this tenant');\n END IF;\n\n -- ── authorization: role → plan. No cap: editing frees nothing and buys\n -- nothing, so charging bookings_month for a reschedule would be wrong.\n v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'appointments', 'edit');\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n -- Duration is preserved across a move — a 60-minute appointment stays 60.\n v_duration := GREATEST(\n COALESCE(EXTRACT(EPOCH FROM (v_row.ends_at - v_row.starts_at)) / 60, 30)::int, 5);\n v_professional := COALESCE(v_professional, v_row.assignee_id);\n\n IF v_wants_move THEN\n IF v_starts_raw IS NOT NULL THEN\n BEGIN\n IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN\n v_starts := v_starts_raw::timestamptz; -- explicit offset\n ELSE\n v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local\n END IF;\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid starts_at: ' || SQLERRM);\n END;\n ELSE\n v_starts := v_row.starts_at;\n END IF;\n v_ends := v_starts + make_interval(mins => v_duration);\n\n IF v_starts > now() + interval '365 days' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be within the next year');\n END IF;\n\n -- Working window (exception-aware, 007) for the target professional.\n IF NOT EXISTS (\n SELECT 1\n FROM agenda_working_windows(\n p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w\n WHERE w.assignee_id = v_professional\n AND (v_starts AT TIME ZONE v_tz)::time >= w.win_start\n AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end\n ) THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'that slot is outside the professional''s working hours. Suggest a different time. Times are tenant-local, tz ' || v_tz);\n END IF;\n\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));\n IF EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = v_professional\n AND b.id <> v_id -- never conflicts with itself\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < v_ends\n AND COALESCE(b.ends_at, b.starts_at) > v_starts\n ) THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'that slot is already taken. Suggest a different time.');\n END IF;\n END IF;\n\n -- ── write + audit ────────────────────────────────────────────────────────\n UPDATE appointments b SET\n starts_at = COALESCE(v_starts, b.starts_at),\n ends_at = COALESCE(v_ends, b.ends_at),\n assignee_id = v_professional,\n status = COALESCE(v_status, b.status),\n notes = COALESCE(v_notes, b.notes),\n metadata = COALESCE(b.metadata, '{}'::jsonb) || jsonb_build_object('lastAgentEdit', now()),\n updated_at = now()\n WHERE b.id = v_id AND b.tenant_id = p_tenant_id;\n\n -- The order mirrors the appointment's assignee/status so the financial side\n -- does not drift from the calendar.\n IF v_row.order_id IS NOT NULL THEN\n UPDATE orders o SET\n assignee_id = v_professional,\n status = CASE WHEN v_status = 'cancelled' THEN 'cancelled' ELSE o.status END,\n updated_at = now()\n WHERE o.id = v_row.order_id AND o.tenant_id = p_tenant_id;\n END IF;\n\n INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant_id, p_actor_user_id, 'agent.updateAppointment', 'appointment', v_id::text,\n jsonb_build_object('payload', p_payload,\n 'previous', jsonb_build_object(\n 'starts_at', v_row.starts_at,\n 'ends_at', v_row.ends_at,\n 'assignee_id', v_row.assignee_id,\n 'status', v_row.status)));\n\n SELECT a.id, a.starts_at, a.ends_at, a.status, a.client_name, a.professional_name\n INTO v_row\n FROM v_appointments a WHERE a.id = v_id;\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_id,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_id, 'resource', 'appointments',\n 'archetype', 'schedule:appointment'),\n 'starts_at', v_row.starts_at,\n 'ends_at', v_row.ends_at,\n 'starts_at_local', to_char(v_row.starts_at AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,\n 'ends_at_local', to_char(v_row.ends_at AT TIME ZONE v_tz, 'HH24:MI'),\n 'client_name', v_row.client_name,\n 'professional_name', v_row.professional_name,\n 'status', v_row.status\n )\n );\nEND;\n$_$;\n\nCREATE FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_assignee_id uuid DEFAULT NULL::uuid) RETURNS TABLE(booking_id uuid, starts_at timestamp with time zone, ends_at timestamp with time zone)\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $_$\nDECLARE\n v_service record;\n v_person_id uuid;\n v_assignee uuid;\n v_requested uuid;\n v_ends timestamptz;\n v_order uuid;\n v_booking uuid;\n v_phone text;\n v_tz text;\n v_notes text;\n v_client_kind text;\nBEGIN\n -- validation: anon-callable, everything tight\n IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN\n RAISE EXCEPTION 'invalid name';\n END IF;\n v_phone := regexp_replace(COALESCE(p_phone, ''), '\\\\D', '', 'g');\n IF length(v_phone) NOT BETWEEN 8 AND 15 THEN\n RAISE EXCEPTION 'invalid phone';\n END IF;\n IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\\\s]+@[^@\\\\s]+\\\\.[^@\\\\s]+$') THEN\n RAISE EXCEPTION 'invalid email';\n END IF;\n v_notes := left(p_notes, 500);\n IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN\n RAISE EXCEPTION 'invalid start';\n END IF;\n\n SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes\n INTO v_service\n FROM services s\n WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id\n AND s.is_active AND s.status = 'active';\n IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;\n -- …and it must be a PUBLIC service, not merely an existing one. A service\n -- released to particular units is not a tenant-wide offering, and this path has\n -- no unit to honour the release in; without this an id from a cached page still\n -- booked what the catalog no longer lists (#201).\n PERFORM public.agenda_assert_service_is_public(p_tenant_id, p_service_id);\n\n v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);\n\n -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),\n COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')\n INTO v_tz, v_client_kind\n FROM tenants t WHERE t.id = p_tenant_id;\n IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;\n IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;\n\n -- resolve the professional, in order of trust:\n -- 1. the id the caller sent (the one the visitor actually saw)\n -- 2. the tenant's configured default (settings->>'defaultAssigneeId')\n -- 3. whoever's working hours cover the slot\n --\n -- Step 2 is why this migration exists. Older published clients call this with\n -- 7 named args, leaving p_assignee_id NULL, and step 3 then resolved to\n -- \"lowest assignee UUID\" — an arbitrary instructor who had nothing to do with\n -- the name the site displayed on the confirmation screen. The default gives\n -- those clients a correct answer without waiting for a redeploy.\n v_requested := COALESCE(\n p_assignee_id,\n (SELECT NULLIF(t.settings ->> 'defaultAssigneeId', '')::uuid\n FROM tenants t WHERE t.id = p_tenant_id)\n );\n\n SELECT w.assignee_id INTO v_assignee\n FROM agenda_working_windows(\n p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, v_requested) w\n WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start\n AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end\n ORDER BY w.assignee_id\n LIMIT 1;\n IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;\n\n -- serialize per (tenant, assignee) to kill the double-book race\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));\n\n IF EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = v_assignee\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < v_ends\n AND COALESCE(b.ends_at, b.starts_at) > p_starts_at\n ) THEN\n RAISE EXCEPTION 'slot no longer available';\n END IF;\n\n -- anti-spam: cap open future public bookings per phone per tenant\n IF (\n SELECT count(*) FROM appointments b\n JOIN people p ON p.id = b.party_id\n WHERE b.tenant_id = p_tenant_id\n AND p.phone = v_phone\n AND b.starts_at > now()\n AND b.status = 'scheduled'\n ) >= 3 THEN\n RAISE EXCEPTION 'too many open bookings for this phone';\n END IF;\n\n -- upsert the client by (tenant, kind, phone), serialized by advisory lock\n -- (kind is tenant-configured, so the customer-only partial index can't\n -- arbitrate)\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));\n\n SELECT p.id INTO v_person_id\n FROM people p\n WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone\n ORDER BY p.created_at\n LIMIT 1;\n\n IF v_person_id IS NOT NULL THEN\n UPDATE people SET\n name = trim(p_name),\n email = COALESCE(p_email, people.email),\n updated_at = now()\n WHERE id = v_person_id;\n ELSE\n INSERT INTO people (tenant_id, kind, name, phone, email, metadata)\n VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,\n jsonb_build_object('source', 'public_booking'))\n RETURNING id INTO v_person_id;\n END IF;\n\n -- order + booking + order_item (statuses hardcoded — caller cannot set)\n INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)\n VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,\n v_service.price, v_service.price, v_notes,\n jsonb_build_object('source', 'public_booking',\n 'serviceNames', v_service.name,\n 'contactName', trim(p_name)))\n RETURNING id INTO v_order;\n\n INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,\n starts_at, ends_at, status, notes, metadata)\n VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,\n p_starts_at, v_ends, 'scheduled', v_notes,\n jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))\n RETURNING id INTO v_booking;\n\n INSERT INTO items (order_id, service_id, name, quantity, unit_price, total,\n sort_order, duration_minutes, assignee_id)\n VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,\n 0, v_service.duration_minutes, v_assignee);\n\n RETURN QUERY SELECT v_booking, p_starts_at, v_ends;\nEND;\n$_$;\n\nCREATE FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid DEFAULT NULL::uuid, p_slot_interval integer DEFAULT 30) RETURNS TABLE(slot_start timestamp with time zone, slot_end timestamp with time zone)\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_tz text;\nBEGIN\n -- tight validation: this function is anon-callable\n IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN\n RAISE EXCEPTION 'invalid duration';\n END IF;\n IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN\n RAISE EXCEPTION 'invalid interval';\n END IF;\n IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN\n RETURN;\n END IF;\n\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM tenants t WHERE t.id = p_tenant_id;\n IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error\n\n RETURN QUERY\n WITH day_schedules AS (\n SELECT w.assignee_id, w.win_start, w.win_end\n FROM agenda_working_windows(p_tenant_id, p_date, p_assignee_id) w\n ),\n candidate AS (\n SELECT\n ds.assignee_id,\n gs AS s_start,\n gs + make_interval(mins => p_duration_minutes) AS s_end\n FROM day_schedules ds\n CROSS JOIN LATERAL generate_series(\n (p_date + ds.win_start) AT TIME ZONE v_tz,\n ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),\n make_interval(mins => p_slot_interval)\n ) AS gs\n )\n SELECT DISTINCT c.s_start, c.s_end\n FROM candidate c\n WHERE NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n -- SMART SYNC (gcal 002): this professional's own bookings OR any\n -- tenant-wide BLOCK (assignee_id IS NULL AND kind='block') busy the slot.\n AND (b.assignee_id = c.assignee_id\n OR (b.assignee_id IS NULL AND b.kind = 'block'))\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < c.s_end\n AND COALESCE(b.ends_at, b.starts_at) > c.s_start\n )\n ORDER BY 1;\nEND;\n$$;\n\nCREATE FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid DEFAULT NULL::uuid) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_action text := nullif(trim(lower(p_action)), '');\n v_target text;\n v_row record;\n v_tz text;\n v_starts timestamptz;\n v_event text;\n v_payload jsonb;\nBEGIN\n IF v_action IS NULL OR v_action NOT IN ('confirm', 'cancel') THEN\n RETURN jsonb_build_object('result', 'not_allowed', 'reason', 'unknown_action');\n END IF;\n IF p_booking_id IS NULL THEN\n RETURN jsonb_build_object('result', 'not_found', 'reason', 'no_booking_id');\n END IF;\n\n v_target := CASE WHEN v_action = 'confirm' THEN 'confirmed' ELSE 'cancelled' END;\n\n -- FOR UPDATE, because two deliveries of the same tap race each other and the\n -- loser must read the state the winner wrote, not the state both started from.\n SELECT b.id, b.tenant_id, b.status, b.starts_at\n INTO v_row\n FROM public.appointments b\n WHERE b.id = p_booking_id\n FOR UPDATE;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('result', 'not_found', 'booking_id', p_booking_id);\n END IF;\n\n IF p_expect_tenant_id IS NOT NULL AND v_row.tenant_id <> p_expect_tenant_id THEN\n RETURN jsonb_build_object('result', 'not_found', 'booking_id', p_booking_id);\n END IF;\n\n -- Already there. The answer is the outcome, not an error: the customer sees\n -- \"confirmado\" either way, and a webhook that 500s on a duplicate is a\n -- webhook Tyxter retries eight times over a day and a half.\n IF v_row.status = v_target THEN\n RETURN jsonb_build_object(\n 'result', v_target, 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n -- Confirming is only meaningful from the two states that mean \"not settled\n -- yet\". 'pending' is the table's own DEFAULT and 'scheduled' is what every\n -- write path sets, so both are live appointments awaiting the customer.\n -- Confirming a completed or cancelled one would rewrite history.\n IF v_action = 'confirm' AND v_row.status NOT IN ('pending', 'scheduled') THEN\n RETURN jsonb_build_object(\n 'result', 'not_allowed', 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n -- Cancelling is allowed from anything that has not already ENDED. A confirmed\n -- appointment can still be cancelled — that is the ordinary case — but one\n -- already cancelled or already completed cannot.\n IF v_action = 'cancel' AND v_row.status IN ('cancelled', 'completed') THEN\n RETURN jsonb_build_object(\n 'result', 'not_allowed', 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n UPDATE public.appointments\n SET status = v_target, updated_at = now()\n WHERE id = v_row.id;\n\n -- ── the fact ──────────────────────────────────────────────────────────────\n -- Emitted HERE and not by the caller. The write happened in the database, so\n -- the browser emitter never sees it (packages/db/migrations/027 is the whole\n -- argument), and a webhook that updated the row and then failed to post the\n -- event would leave a confirmed appointment nobody was told about.\n --\n -- The payload is BOOKING_FIELDS from plugin-agenda's message-events.ts, field\n -- for field, so an automation reacting to a WhatsApp confirmation renders the\n -- same message as one reacting to a confirmation made in the app.\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM public.tenants t WHERE t.id = v_row.tenant_id;\n v_tz := COALESCE(v_tz, 'America/Sao_Paulo');\n v_starts := v_row.starts_at;\n\n SELECT jsonb_build_object(\n 'customer_name', COALESCE(a.client_name, ''),\n 'service_name', COALESCE((\n SELECT string_agg(s->>'name', ' + ')\n FROM jsonb_array_elements(COALESCE(a.services::jsonb, '[]'::jsonb)) AS s), ''),\n 'professional_name', COALESCE(a.professional_name, ''),\n 'booking_date', to_char(v_starts AT TIME ZONE v_tz, 'DD/MM/YYYY'),\n 'booking_time', to_char(v_starts AT TIME ZONE v_tz, 'HH24:MI'),\n 'location_name', COALESCE(a.location_name, ''),\n 'customer_phone', COALESCE(a.client_phone, ''),\n 'source', 'channel_reply')\n INTO v_payload\n FROM public.v_appointments a\n WHERE a.id = v_row.id;\n\n v_event := CASE WHEN v_action = 'confirm'\n THEN 'agenda.booking_confirmed'\n ELSE 'agenda.booking_cancelled' END;\n\n PERFORM public.plg_emit_event(\n v_event,\n COALESCE(v_payload, '{}'::jsonb),\n 'booking',\n v_row.id::text,\n v_row.tenant_id,\n NULL);\n\n RETURN jsonb_build_object(\n 'result', v_target, 'changed', true, 'event_key', v_event,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id,\n 'status', v_target, 'previous_status', v_row.status);\nEND;\n$$;\n\n\n-- ── view ────────────────────────────────────────────────────────────────\n\nCREATE VIEW public.v_appointments WITH (security_invoker='true') AS\n SELECT b.id,\n b.tenant_id,\n b.kind,\n b.starts_at,\n b.ends_at,\n b.status,\n b.notes,\n b.order_id,\n b.location_id,\n b.metadata,\n b.created_at,\n b.updated_at,\n b.party_id AS client_id,\n COALESCE(pc.name, (b.metadata ->> 'title'::text)) AS client_name,\n pc.phone AS client_phone,\n pc.email AS client_email,\n pc.avatar_url AS client_avatar_url,\n b.assignee_id AS professional_id,\n ps.name AS professional_name,\n ps.avatar_url AS professional_avatar_url,\n l.name AS location_name,\n o.total AS order_total,\n o.status AS order_status,\n ((EXTRACT(epoch FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at)))::integer / 60) AS total_duration_minutes,\n o.reference_number,\n o.status AS stage,\n (o.metadata ->> 'direction'::text) AS direction,\n ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg\n FROM public.items oi\n WHERE (oi.order_id = b.order_id)) AS services,\n inv.status AS payment_status,\n inv.paid AS order_paid,\n inv.balance AS order_balance\n FROM (((((public.appointments b\n LEFT JOIN public.people pc ON ((pc.id = b.party_id)))\n LEFT JOIN public.people ps ON ((ps.id = b.assignee_id)))\n LEFT JOIN public.locations l ON ((l.id = b.location_id)))\n LEFT JOIN public.orders o ON ((o.id = b.order_id)))\n LEFT JOIN public.v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));\n\nCREATE VIEW public.rep_appointments_by_period WITH (security_invoker='true') AS\n SELECT tenant_id,\n id AS booking_id,\n (starts_at)::date AS date,\n starts_at,\n client_id,\n client_name,\n professional_id,\n professional_name,\n COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,\n status,\n order_total AS revenue,\n created_at,\n updated_at\n FROM public.v_appointments b;\n\nCREATE VIEW public.plg_agenda_rep_bookings WITH (security_invoker='true') AS\n SELECT tenant_id,\n booking_id,\n date,\n starts_at,\n client_id,\n client_name,\n professional_id,\n professional_name,\n service_name,\n status,\n revenue AS booked_revenue,\n CASE\n WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN (0)::numeric\n ELSE COALESCE(revenue, (0)::numeric)\n END AS effective_revenue,\n CASE\n WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN COALESCE(revenue, (0)::numeric)\n ELSE (0)::numeric\n END AS lost_revenue,\n CASE\n WHEN (status = 'completed'::text) THEN 1\n ELSE 0\n END AS is_completed,\n CASE\n WHEN (status = 'cancelled'::text) THEN 1\n ELSE 0\n END AS is_cancelled,\n CASE\n WHEN (status = 'no_show'::text) THEN 1\n ELSE 0\n END AS is_no_show,\n created_at,\n updated_at\n FROM public.rep_appointments_by_period b;\n\nCREATE VIEW public.rep_confirmation_queue WITH (security_invoker='true') AS\n SELECT tenant_id,\n id AS booking_id,\n (starts_at)::date AS date,\n starts_at,\n client_id,\n client_name,\n professional_id,\n professional_name,\n COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,\n 'pending'::text AS confirmation_status,\n NULL::text AS confirmation_channel,\n NULL::timestamp with time zone AS confirmation_sent_at,\n NULL::timestamp with time zone AS confirmed_at,\n created_at,\n updated_at\n FROM public.v_appointments b\n WHERE ((status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (starts_at >= (now() - '1 day'::interval)));\n\nCREATE VIEW public.rep_revenue_by_professional WITH (security_invoker='true') AS\n SELECT tenant_id,\n (starts_at)::date AS date,\n professional_id,\n COALESCE(professional_name, 'Sem profissional'::text) AS professional_name,\n (count(*))::integer AS appointment_count,\n (COALESCE(sum(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS total_revenue,\n (COALESCE(avg(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS avg_ticket,\n (0)::numeric(14,2) AS commission,\n max(updated_at) AS updated_at\n FROM public.v_appointments b\n WHERE (status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))\n GROUP BY tenant_id, ((starts_at)::date), professional_id, COALESCE(professional_name, 'Sem profissional'::text);\n\nCREATE VIEW public.rep_revenue_by_service WITH (security_invoker='true') AS\n SELECT b.tenant_id,\n (b.starts_at)::date AS date,\n NULLIF((service_item.value ->> 'serviceId'::text), ''::text) AS service_id,\n COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text) AS service_name,\n (count(DISTINCT b.id))::integer AS quantity,\n (COALESCE(sum((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS total_revenue,\n (COALESCE(avg((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS avg_ticket,\n max(b.updated_at) AS updated_at\n FROM (public.v_appointments b\n CROSS JOIN LATERAL jsonb_array_elements(COALESCE((b.services)::jsonb, '[]'::jsonb)) service_item(value))\n WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))\n GROUP BY b.tenant_id, ((b.starts_at)::date), NULLIF((service_item.value ->> 'serviceId'::text), ''::text), COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text);\n\nCREATE VIEW public.v_appointments_sem_order WITH (security_invoker='true') AS\n SELECT id AS appointment_id,\n tenant_id,\n unit_id,\n kind,\n status,\n party_id,\n assignee_id,\n starts_at\n FROM public.appointments a\n WHERE ((order_id IS NULL) AND (status = ANY (ARRAY['completed'::text, 'invoiced'::text, 'paid'::text])));\n\nCREATE VIEW public.v_public_services AS\n SELECT id,\n tenant_id,\n name,\n description,\n price,\n currency,\n COALESCE(duration_minutes, 30) AS duration_minutes,\n image_url,\n COALESCE(((metadata ->> 'sort_order'::text))::integer, 0) AS sort_order\n FROM public.products s\n WHERE ((kind = 'service'::text) AND is_active AND (status = 'active'::text) AND COALESCE(((metadata ->> 'bookableOnline'::text))::boolean, true) AND (NOT (EXISTS ( SELECT 1\n FROM app.resource_grants g\n WHERE ((g.tenant_id = s.tenant_id) AND (g.resource_type = 'catalog.product'::text) AND (g.resource_id = s.id) AND (g.relation = 'available_in'::text))))));\n\n\n-- ── acl ─────────────────────────────────────────────────────────────────\n\nREVOKE ALL ON FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text, p_notes text, p_assignee_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text, p_notes text, p_assignee_id uuid) TO authenticated;\nGRANT ALL ON FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text, p_notes text, p_assignee_id uuid) TO service_role;\nGRANT ALL ON FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text, p_notes text, p_assignee_id uuid) TO anon;\n\nREVOKE ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO authenticated;\nGRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO service_role;\nGRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO anon;\n\nREVOKE ALL ON FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid) TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_appointments TO anon;\nGRANT ALL ON TABLE public.v_appointments TO authenticated;\nGRANT ALL ON TABLE public.v_appointments TO service_role;\n\nGRANT MAINTAIN ON TABLE public.rep_appointments_by_period TO anon;\nGRANT ALL ON TABLE public.rep_appointments_by_period TO authenticated;\nGRANT ALL ON TABLE public.rep_appointments_by_period TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_agenda_rep_bookings TO anon;\nGRANT ALL ON TABLE public.plg_agenda_rep_bookings TO authenticated;\nGRANT ALL ON TABLE public.plg_agenda_rep_bookings TO service_role;\n\nGRANT MAINTAIN ON TABLE public.rep_confirmation_queue TO anon;\nGRANT ALL ON TABLE public.rep_confirmation_queue TO authenticated;\nGRANT ALL ON TABLE public.rep_confirmation_queue TO service_role;\n\nGRANT MAINTAIN ON TABLE public.rep_revenue_by_professional TO anon;\nGRANT ALL ON TABLE public.rep_revenue_by_professional TO authenticated;\nGRANT ALL ON TABLE public.rep_revenue_by_professional TO service_role;\n\nGRANT MAINTAIN ON TABLE public.rep_revenue_by_service TO anon;\nGRANT ALL ON TABLE public.rep_revenue_by_service TO authenticated;\nGRANT ALL ON TABLE public.rep_revenue_by_service TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_appointments_sem_order TO anon;\nGRANT ALL ON TABLE public.v_appointments_sem_order TO authenticated;\nGRANT ALL ON TABLE public.v_appointments_sem_order TO service_role;\n\nGRANT SELECT,MAINTAIN ON TABLE public.v_public_services TO anon;\nGRANT ALL ON TABLE public.v_public_services TO authenticated;\nGRANT ALL ON TABLE public.v_public_services TO service_role;\n\n\n-- ── comment ─────────────────────────────────────────────────────────────\n\nCOMMENT ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) IS 'A booking, its Venda and its lines in one transaction (PRD 06). Replaces the three browser calls createBooking used to make — closing the tab between them is what left an order nobody could find.';\n\nCOMMENT ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) IS 'Bill a booking in one call (PRD 06). The bridge used to read the order in the browser and then call the invoice RPC; here the lookup and the promotion are one transaction.';\n\nCOMMENT ON VIEW public.v_appointments_sem_order IS 'Appointments that reached a billable state with no Venda behind them (PRD 06). An unbilled appointment is legitimate; a COMPLETED one with nothing commercial attached is the 72% hole the V1 has, arriving early enough to stop.';\n\nCOMMENT ON VIEW public.v_public_services IS 'Anonymous booking catalog. Owner-rights on purpose (anon has no tenant claim), so the release rule is written into the body: only services with no available_in tuple are public — see #201.';\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_THE_CONNECTOR_DEFINERS_KEEP_A_PINNED_PATH = `-- ---------------------------------------------------------------------------\n-- 001_the_connector_definers_keep_a_pinned_path — the half of 019 the squash\n-- could not photograph (#283, #338).\n--\n-- 019 pinned \\`search_path\\` on every SECURITY DEFINER function this plugin owns,\n-- including \\`fn_gcal_push_booking\\`, which the GOOGLE CALENDAR CONNECTOR creates.\n-- Connector SQL is its own ledger source: it is not in chain.json, so it is not\n-- in the schema \\`emit-baseline\\` photographs, so 000_baseline.sql cannot carry\n-- the pin the way it carries the other 40.\n--\n-- Without this file a fresh install applies the connector's own 001-005, which\n-- create the function unpinned, and nothing ever pins it again — a definer\n-- function resolving names the caller's way, which is the exact shape #283\n-- exists to close. So 019's loop stays a migration.\n--\n-- Idempotent by construction: it pins only what is unpinned, and skips the\n-- function entirely on an install without the connector.\n-- ---------------------------------------------------------------------------\n\nDO $pin$\nDECLARE\n v_fn record;\n v_pinned integer := 0;\nBEGIN\n FOR v_fn IN\n SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args\n FROM pg_proc p\n JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public'\n AND p.prosecdef\n AND p.proname = ANY (ARRAY['create_public_booking', 'fn_gcal_push_booking'])\n AND NOT EXISTS (\n SELECT 1 FROM unnest(coalesce(p.proconfig, ARRAY[]::text[])) AS cfg\n WHERE cfg LIKE 'search\\\\_path=%'\n )\n LOOP\n -- \\`public, pg_catalog\\`, not '': these bodies read public objects by bare\n -- name and were written that way. What closes the hole is that the path is\n -- FIXED rather than inherited — the booking path is called by an anonymous\n -- shopper, and that caller's path should count for nothing.\n EXECUTE format('ALTER FUNCTION public.%I(%s) SET search_path = public, pg_catalog',\n v_fn.proname, v_fn.args);\n v_pinned := v_pinned + 1;\n END LOOP;\n IF v_pinned > 0 THEN\n RAISE NOTICE 'plugin-agenda: pinned search_path on % connector definer function(s)', v_pinned;\n END IF;\nEND $pin$;\n`\n\nexport const MIGRATION_002_THE_APPOINTMENT_NAMES_THE_UNIT = `-- ============================================================================\n-- 002_the_appointment_names_the_unit — v_appointments leaves the locations\n-- projection (#214 / #352).\n--\n-- The view LEFT JOINed public.locations, a compat projection of app.units. A\n-- view is a HARD dependency, so this join is what made the projection\n-- undroppable by the spine — which runs first and cannot remove a name a\n-- plugin applied later still selects.\n--\n-- The view is already security_invoker and app.units already grants SELECT to\n-- authenticated under its own RLS, so nothing about who sees which appointment\n-- changes: the same policy decides, one relation earlier.\n-- ============================================================================\n\nCREATE OR REPLACE VIEW public.v_appointments WITH (security_invoker='true') AS\n SELECT b.id,\n b.tenant_id,\n b.kind,\n b.starts_at,\n b.ends_at,\n b.status,\n b.notes,\n b.order_id,\n b.location_id,\n b.metadata,\n b.created_at,\n b.updated_at,\n b.party_id AS client_id,\n COALESCE(pc.name, (b.metadata ->> 'title'::text)) AS client_name,\n pc.phone AS client_phone,\n pc.email AS client_email,\n pc.avatar_url AS client_avatar_url,\n b.assignee_id AS professional_id,\n ps.name AS professional_name,\n ps.avatar_url AS professional_avatar_url,\n l.name AS location_name,\n o.total AS order_total,\n o.status AS order_status,\n ((EXTRACT(epoch FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at)))::integer / 60) AS total_duration_minutes,\n o.reference_number,\n o.status AS stage,\n (o.metadata ->> 'direction'::text) AS direction,\n ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg\n FROM public.items oi\n WHERE (oi.order_id = b.order_id)) AS services,\n inv.status AS payment_status,\n inv.paid AS order_paid,\n inv.balance AS order_balance\n FROM (((((public.appointments b\n LEFT JOIN public.people pc ON ((pc.id = b.party_id)))\n LEFT JOIN public.people ps ON ((ps.id = b.assignee_id)))\n LEFT JOIN app.units l ON ((l.id = b.location_id)))\n LEFT JOIN public.orders o ON ((o.id = b.order_id)))\n LEFT JOIN public.v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));`\n\nexport const MIGRATION_003_THE_CLIENT_ARRIVES_BEFORE_BEING_SERVED = `-- 003_the_client_arrives_before_being_served.sql\n--\n-- Falta um estado entre \"confirmado\" e \"em atendimento\": AGUARDANDO.\n--\n-- É o estado que o check-in produz — a cliente chegou, está na recepção, ainda\n-- não sentou na cadeira. Hoje o ciclo do \\`appointment\\` vai de \\`confirmed\\`\n-- direto para \\`in_progress\\`, e quem recebe é obrigado a escolher entre mentir\n-- (dizer que já está em atendimento) ou não registrar nada. As duas respostas\n-- apagam a informação que a recepção mais usa: quem já está aqui.\n--\n-- No V1 esse estado existe e é o padrão da tela de check-in, ao lado de\n-- \\`scheduled\\`, \\`confirmed\\` e \\`in_progress\\`. Portá-lo é o que permite a tela\n-- fazer sentido — sem ele, \"fazer check-in\" não teria o que gravar.\n--\n-- Entra em \\`appointment\\` e em \\`service_order\\`: são a mesma coisa com nomes\n-- diferentes, e uma ordem de serviço também tem alguém esperando.\n\nUPDATE public.order_kinds\n SET statuses = (\n SELECT array_agg(DISTINCT s ORDER BY s)\n FROM unnest(coalesce(statuses, ARRAY[]::text[]) || ARRAY['waiting']) AS s\n ),\n updated_at = now()\n WHERE kind IN ('appointment', 'service_order')\n AND NOT ('waiting' = ANY (coalesce(statuses, ARRAY[]::text[])));\n`\n\nexport const MIGRATION_004_THE_APPOINTMENT_CARRIES_ITS_UNIT = `-- 004_the_appointment_carries_its_unit.sql\n--\n-- Um agendamento criado PELA TELA nasce com \\`unit_id\\` NULO: o formulário coleta\n-- o local e o provider nunca envia a unidade. E \\`location_id\\` já É uma unidade —\n-- aponta para o mesmo \\`app.units\\`.\n--\n-- O efeito não é cosmético. O escopo por unidade é o que faz a recepção de uma\n-- filial ver a fila DELA; uma linha sem unidade não é filtrada por unidade\n-- nenhuma, então ela aparece para TODAS. Num tenant de franquia — e a rede\n-- sintética tem seis unidades — isso é atendimento de uma filial visível na\n-- recepção da outra.\n--\n-- Apareceu numa jornada real: duas clientes no mesmo dia, uma marcada pela tela\n-- (sem unidade) e outra com unidade; a recepção de Norte — Centro via a primeira\n-- e não a segunda. O filtro estava certo. O dado é que chegava incompleto.\n--\n-- POR QUE GATILHO, E NÃO SÓ O CAMPO NO PROVIDER. O provider é um escritor entre\n-- vários: o agente cria agendamento, a importação cria, o totem cria. Corrigir\n-- só a tela deixaria os outros escrevendo linha sem unidade — e o furo é de\n-- VISIBILIDADE, que não se conserta pela metade.\nCREATE OR REPLACE FUNCTION public.trg_agenda_stamp_unit()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $$\nBEGIN\n IF NEW.unit_id IS NULL AND NEW.location_id IS NOT NULL THEN\n NEW.unit_id := NEW.location_id;\n END IF;\n RETURN NEW;\nEND $$;\n\nCOMMENT ON FUNCTION public.trg_agenda_stamp_unit() IS\n 'Carimba a unidade do agendamento a partir do local, que é a mesma app.units. Sem ela a linha escapa do escopo por unidade e aparece para todas as filiais (004).';\n\nDROP TRIGGER IF EXISTS appointments_stamp_unit ON public.appointments;\nCREATE TRIGGER appointments_stamp_unit\n BEFORE INSERT OR UPDATE OF location_id, unit_id ON public.appointments\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_agenda_stamp_unit();\n\n-- ── e os que já nasceram sem ───────────────────────────────────────────────\nUPDATE public.appointments\n SET unit_id = location_id\n WHERE unit_id IS NULL AND location_id IS NOT NULL;\n`\n\nexport const MIGRATION_005_THE_QUEUE_KNOWS_HOW_LONG_SHE_WAITED = `-- 005_the_queue_knows_how_long_she_waited.sql\n--\n-- A fila mostra QUEM chegou e não HÁ QUANTO TEMPO. Quem está esperando há\n-- quarenta minutos e quem acabou de entrar aparecem iguais, e é justamente essa\n-- diferença que decide quem a recepção chama primeiro.\n--\n-- O V1 mede por \\`checked_in_at\\`, e o comentário dele explica por que não serve\n-- \\`updated_at\\`: qualquer edição no agendamento o reescreve, então trocar uma\n-- observação às 14h faria a cliente que chegou às 13h parecer recém-chegada. Um\n-- relógio que qualquer coisa reinicia não é relógio.\n--\n-- O carimbo é do BANCO, e não da tela: quem faz check-in pode ser a recepção\n-- pelo navegador, o totem, ou o próprio agente. Três escritores, um só instante\n-- verdadeiro — e o relógio do servidor é o único que os três compartilham.\nALTER TABLE public.appointments\n ADD COLUMN IF NOT EXISTS checked_in_at timestamptz;\n\nCOMMENT ON COLUMN public.appointments.checked_in_at IS\n 'Quando a pessoa CHEGOU. Não use updated_at para isso: qualquer edição o reescreve e o tempo de espera mente (005).';\n\n-- ── e o carimbo acontece na transição, não no cliente ──────────────────────\nCREATE OR REPLACE FUNCTION public.trg_agenda_stamp_checkin()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $$\nBEGIN\n -- Só na ENTRADA em waiting: reentrar no estado (uma correção de status, um\n -- salvamento qualquer) não pode reiniciar a espera de quem já está sentada.\n IF NEW.status = 'waiting' AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM 'waiting')\n AND NEW.checked_in_at IS NULL THEN\n NEW.checked_in_at := now();\n END IF;\n RETURN NEW;\nEND $$;\n\nCOMMENT ON FUNCTION public.trg_agenda_stamp_checkin() IS\n 'Carimba a chegada ao ENTRAR em waiting, uma vez. Reentrar no estado não reinicia a espera (005).';\n\nDROP TRIGGER IF EXISTS appointments_stamp_checkin ON public.appointments;\nCREATE TRIGGER appointments_stamp_checkin\n BEFORE INSERT OR UPDATE OF status ON public.appointments\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_agenda_stamp_checkin();\n\n-- Quem já está esperando desde antes deste arquivo herda o último instante\n-- conhecido — impreciso, e ainda assim melhor que um travessão.\nUPDATE public.appointments\n SET checked_in_at = updated_at\n WHERE status = 'waiting' AND checked_in_at IS NULL;\n`\n\nexport const MIGRATION_006_THE_AGENDA_STOPS_PRICING_EVERY_INVOICE = `-- ---------------------------------------------------------------------------\n-- 006_the_agenda_stops_pricing_every_invoice.sql — ler DOIS agendamentos deixa\n-- de custar a apuração de 8.134 faturas.\n--\n-- O SINTOMA: \\`GET /rest/v1/v_appointments?...\\` devolvendo 500 com\n-- \\`57014: canceling statement due to statement timeout\\`, de forma reprodutível,\n-- ao abrir a agenda depois que existe agendamento na semana. Enquanto a semana\n-- estava vazia a mesma consulta respondia em 12ms — o que escondeu o problema e\n-- fez ele parecer intermitente.\n--\n-- ── A medição ──────────────────────────────────────────────────────────────\n-- EXPLAIN ANALYZE da consulta exata, com 2 agendamentos na janela:\n--\n-- Nested Loop Left Join actual time=17137..21252 rows=2\n-- Join Filter: (o_1.id = b.order_id)\n-- Rows Removed by Join Filter: 16268 ← dezesseis mil\n-- -> Nested Loop ... actual time=5619..10590 rows=8134 loops=2\n-- Execution Time: 21272 ms\n--\n-- \\`v_invoice_balances\\` foi calculada POR INTEIRO — as 8.134 faturas do tenant —\n-- e DUAS VEZES, uma por agendamento. Só depois o \\`Join Filter\\` jogou fora\n-- 16.268 das 16.270 linhas para ficar com as 2 que interessavam.\n--\n-- ── Por que o filtro não desceu ────────────────────────────────────────────\n-- A view fazia:\n--\n-- LEFT JOIN v_invoice_balances inv ON inv.invoice_id = b.order_id\n--\n-- Isso é um JOIN entre duas relações, não um filtro constante. O planejador não\n-- tem por onde empurrar \\`b.order_id\\` para dentro de um agregado do outro lado:\n-- ele precisa produzir o lado direito inteiro para então casar as chaves. E o\n-- lado direito é um \\`GROUP BY order_id\\` sobre a árvore financeira inteira\n-- (faturas → parcelas → alocações → movimentos), cada nível pagando a RLS por\n-- linha. 8.134 linhas × ~0,45ms de política × três níveis é o orçamento todo.\n--\n-- ── A correção ─────────────────────────────────────────────────────────────\n-- \\`LEFT JOIN LATERAL\\`. A correlação deixa de ser uma condição de join e vira um\n-- PARÂMETRO da subconsulta: \\`WHERE vb.invoice_id = b.order_id\\` é avaliado com\n-- \\`b.order_id\\` já conhecido, e \\`invoice_id\\` é exatamente a chave de agrupamento\n-- de \\`v_invoice_balances\\` (\\`GROUP BY t.order_id\\`, depois \\`o.id = tb.order_id\\`).\n-- Um qual sobre a chave de agrupamento desce por baixo do agregado, e o que\n-- era uma varredura de 8.134 faturas vira uma sondagem de índice de UMA.\n--\n-- Nada muda no que a view devolve: mesmas colunas, mesma ordem, mesmos tipos —\n-- \\`LEFT JOIN LATERAL … ON true\\` tem exatamente a semântica do LEFT JOIN que\n-- substitui, inclusive o NULL quando não há fatura.\n--\n-- ── O que esta migration NÃO conserta ──────────────────────────────────────\n-- O multiplicador continua sendo a RLS por linha (~0,45ms por linha por\n-- tabela). Aqui ele deixa de doer porque o número de linhas cai de 8.134 para\n-- 1, não porque ficou barato. Qualquer outra leitura que varra as tabelas\n-- financeiras inteiras vai reencontrá-lo — ver 016 e 019 para a forma de tirar\n-- pergunta constante de dentro do laço.\n--\n-- MORA NA CORRENTE DA AGENDA, e não na do núcleo, porque redefine\n-- \\`v_appointments\\` — view da agenda — lendo \\`v_invoice_balances\\`, view do\n-- financeiro. A corrente é núcleo → financeiro → … → agenda, então este é o\n-- único degrau em que as duas já existem. Nascida como 021 no núcleo, ela\n-- falhava no \\`apply\\` com \"relation v_invoice_balances does not exist\", e o\n-- db-from-zero foi quem disse isso em voz alta.\n-- ---------------------------------------------------------------------------\n\n-- \\`WITH (security_invoker = true)\\` explícito: \\`CREATE OR REPLACE VIEW\\` NÃO\n-- herda as opções da view que substitui, e sem isto ela volta a rodar com os\n-- direitos do dono — as políticas de quem lê deixam de valer. É o que a\n-- asserção N16 da postura grada, e foi ela que pegou.\nCREATE OR REPLACE VIEW public.v_appointments WITH (security_invoker = true) AS\n SELECT b.id,\n b.tenant_id,\n b.kind,\n b.starts_at,\n b.ends_at,\n b.status,\n b.notes,\n b.order_id,\n b.location_id,\n b.metadata,\n b.created_at,\n b.updated_at,\n b.party_id AS client_id,\n COALESCE(pc.name, b.metadata ->> 'title'::text) AS client_name,\n pc.phone AS client_phone,\n pc.email AS client_email,\n pc.avatar_url AS client_avatar_url,\n b.assignee_id AS professional_id,\n ps.name AS professional_name,\n ps.avatar_url AS professional_avatar_url,\n l.name AS location_name,\n o.total AS order_total,\n o.status AS order_status,\n EXTRACT(epoch FROM COALESCE(b.ends_at, b.starts_at) - b.starts_at)::integer / 60 AS total_duration_minutes,\n o.reference_number,\n o.status AS stage,\n o.metadata ->> 'direction'::text AS direction,\n ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg\n FROM items oi\n WHERE oi.order_id = b.order_id) AS services,\n inv.status AS payment_status,\n inv.paid AS order_paid,\n inv.balance AS order_balance\n FROM appointments b\n LEFT JOIN people pc ON pc.id = b.party_id\n LEFT JOIN people ps ON ps.id = b.assignee_id\n LEFT JOIN app.units l ON l.id = b.location_id\n LEFT JOIN orders o ON o.id = b.order_id\n -- LATERAL e não JOIN: assim \\`b.order_id\\` é PARÂMETRO da subconsulta e o\n -- filtro desce por baixo do \\`GROUP BY order_id\\` de v_invoice_balances.\n -- Como JOIN, o agregado inteiro (8.134 faturas) era produzido por\n -- agendamento e descartado depois — 21s para devolver 2 linhas.\n LEFT JOIN LATERAL (\n SELECT vb.status, vb.paid, vb.balance\n FROM v_invoice_balances vb\n WHERE vb.invoice_id = b.order_id\n LIMIT 1\n ) inv ON true;\n`\n\nexport const MIGRATION_007_O_CASCADE_LEVOU_A_AGENDA_JUNTO = `-- O CASCADE levou a agenda junto.\n--\n-- Sintoma: a tela de check-in (\\`#/agenda/checkin\\`) demora e não abre. A causa\n-- não está na agenda — está numa migration do FINANCEIRO, escrita três dias\n-- antes.\n--\n-- As migrations 044 e 045 do plugin-financial começam com:\n--\n-- DROP VIEW IF EXISTS public.v_financial_invoice_balances CASCADE;\n--\n-- e recriam, logo abaixo, \\`v_financial_invoice_balances\\` e \\`v_invoice_balances\\`\n-- — as duas que o autor (eu) sabia que dependiam. O CASCADE, porém, não se\n-- limita ao que o autor conhece: ele derruba a árvore inteira. Debaixo de\n-- \\`v_invoice_balances\\` moravam mais seis views, de dois plugins:\n--\n-- v_appointments (agenda — o read model do agendamento)\n-- ├── rep_appointments_by_period\n-- │ └── plg_agenda_rep_bookings\n-- ├── rep_confirmation_queue\n-- ├── rep_revenue_by_professional\n-- └── rep_revenue_by_service\n-- plg_financial_rep_receivables (financeiro — restaurada na 053 de lá)\n--\n-- \\`v_appointments\\` lê \\`v_invoice_balances\\` desde a migration 006 desta corrente\n-- (o LATERAL que fez a agenda parar de apurar 8.134 faturas por agendamento).\n-- Essa dependência foi o conserto de um problema real e continua certa; o que\n-- ela criou, sem ninguém notar, foi uma aresta do financeiro para a agenda —\n-- e é por ela que o CASCADE passou.\n--\n-- Nada gritou. O \\`DROP\\` não avisa o que levou, o \\`apply\\` das duas migrations\n-- passou verde, a lista de contas a pagar (o que eu estava consertando) ficou\n-- correta, e a agenda só falha quando alguém a abre. O erro que chega ao\n-- navegador é um 404 do PostgREST sobre uma relação que o código tem certeza\n-- de que existe — pista que aponta para o lugar errado.\n--\n-- ── O que esta migration faz ────────────────────────────────────────────────\n--\n-- Recria as cinco views da agenda, com o corpo EXATO em que estavam: o de\n-- \\`v_appointments\\` vem da migration 006 (a forma com LATERAL, não a do\n-- baseline), e os quatro relatórios vêm do baseline, que não os alterou desde\n-- então. Nenhuma coluna muda, nenhuma regra muda — o que muda é existirem.\n--\n-- \\`WITH (security_invoker = true)\\` explícito em todas: uma view recriada não\n-- herda opção nenhuma, e sem isso ela passa a rodar com os direitos do dono —\n-- a RLS de quem lê deixa de valer. Os GRANTs também são reescritos, porque\n-- morreram com as views.\n--\n-- ── A lição, para a próxima ─────────────────────────────────────────────────\n--\n-- Antes de um \\`DROP … CASCADE\\`, pergunte ao banco quem depende:\n--\n-- SELECT DISTINCT dependente.relname\n-- FROM pg_depend d\n-- JOIN pg_rewrite r ON r.oid = d.objid\n-- JOIN pg_class dependente ON dependente.oid = r.ev_class\n-- WHERE d.refobjid = 'public.v_financial_invoice_balances'::regclass\n-- AND dependente.relname <> 'v_financial_invoice_balances';\n--\n-- A lista que voltar é a lista que a migration tem que recriar. E depois de\n-- aplicar, o diff entre as views que o código declara e as que o banco tem é o\n-- que pega o que escapou — foi ele que encontrou estas seis.\n--\n-- Idempotente.\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 1. v_appointments — corpo da migration 006 (LATERAL sobre v_invoice_balances)\n-- ─────────────────────────────────────────────────────────────────────────────\n\nCREATE OR REPLACE VIEW public.v_appointments WITH (security_invoker = true) AS\n SELECT b.id,\n b.tenant_id,\n b.kind,\n b.starts_at,\n b.ends_at,\n b.status,\n b.notes,\n b.order_id,\n b.location_id,\n b.metadata,\n b.created_at,\n b.updated_at,\n b.party_id AS client_id,\n COALESCE(pc.name, b.metadata ->> 'title'::text) AS client_name,\n pc.phone AS client_phone,\n pc.email AS client_email,\n pc.avatar_url AS client_avatar_url,\n b.assignee_id AS professional_id,\n ps.name AS professional_name,\n ps.avatar_url AS professional_avatar_url,\n l.name AS location_name,\n o.total AS order_total,\n o.status AS order_status,\n EXTRACT(epoch FROM COALESCE(b.ends_at, b.starts_at) - b.starts_at)::integer / 60 AS total_duration_minutes,\n o.reference_number,\n o.status AS stage,\n o.metadata ->> 'direction'::text AS direction,\n ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg\n FROM items oi\n WHERE oi.order_id = b.order_id) AS services,\n inv.status AS payment_status,\n inv.paid AS order_paid,\n inv.balance AS order_balance\n FROM appointments b\n LEFT JOIN people pc ON pc.id = b.party_id\n LEFT JOIN people ps ON ps.id = b.assignee_id\n LEFT JOIN app.units l ON l.id = b.location_id\n LEFT JOIN orders o ON o.id = b.order_id\n LEFT JOIN LATERAL (\n SELECT vb.status, vb.paid, vb.balance\n FROM v_invoice_balances vb\n WHERE vb.invoice_id = b.order_id\n LIMIT 1\n ) inv ON true;\n\nCOMMENT ON VIEW public.v_appointments IS\n 'O read model do agendamento: a linha do calendário com cliente, '\n 'profissional, unidade, itens e o estado de pagamento da Venda. Lê '\n 'v_invoice_balances por LATERAL para que o filtro desça por baixo do '\n 'agregado — como JOIN, a agenda apurava as faturas do tenant inteiro por '\n 'agendamento.';\n\nGRANT SELECT ON TABLE public.v_appointments TO authenticated, service_role;\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 2. Os quatro relatórios que penduram nela, e o quinto que pendura no primeiro\n-- ─────────────────────────────────────────────────────────────────────────────\n\nCREATE OR REPLACE VIEW public.rep_appointments_by_period WITH (security_invoker = true) AS\n SELECT tenant_id,\n id AS booking_id,\n (starts_at)::date AS date,\n starts_at,\n client_id,\n client_name,\n professional_id,\n professional_name,\n COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,\n status,\n order_total AS revenue,\n created_at,\n updated_at\n FROM public.v_appointments b;\n\nGRANT SELECT ON TABLE public.rep_appointments_by_period TO authenticated, service_role;\n\nCREATE OR REPLACE VIEW public.plg_agenda_rep_bookings WITH (security_invoker = true) AS\n SELECT tenant_id,\n booking_id,\n date,\n starts_at,\n client_id,\n client_name,\n professional_id,\n professional_name,\n service_name,\n status,\n revenue AS booked_revenue,\n CASE\n WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN (0)::numeric\n ELSE COALESCE(revenue, (0)::numeric)\n END AS effective_revenue,\n CASE\n WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN COALESCE(revenue, (0)::numeric)\n ELSE (0)::numeric\n END AS lost_revenue,\n CASE\n WHEN (status = 'completed'::text) THEN 1\n ELSE 0\n END AS is_completed,\n CASE\n WHEN (status = 'cancelled'::text) THEN 1\n ELSE 0\n END AS is_cancelled,\n CASE\n WHEN (status = 'no_show'::text) THEN 1\n ELSE 0\n END AS is_no_show,\n created_at,\n updated_at\n FROM public.rep_appointments_by_period b;\n\nGRANT SELECT ON TABLE public.plg_agenda_rep_bookings TO authenticated, service_role;\n\nCREATE OR REPLACE VIEW public.rep_confirmation_queue WITH (security_invoker = true) AS\n SELECT tenant_id,\n id AS booking_id,\n (starts_at)::date AS date,\n starts_at,\n client_id,\n client_name,\n professional_id,\n professional_name,\n COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,\n 'pending'::text AS confirmation_status,\n NULL::text AS confirmation_channel,\n NULL::timestamp with time zone AS confirmation_sent_at,\n NULL::timestamp with time zone AS confirmed_at,\n created_at,\n updated_at\n FROM public.v_appointments b\n WHERE ((status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (starts_at >= (now() - '1 day'::interval)));\n\nGRANT SELECT ON TABLE public.rep_confirmation_queue TO authenticated, service_role;\n\nCREATE OR REPLACE VIEW public.rep_revenue_by_professional WITH (security_invoker = true) AS\n SELECT tenant_id,\n (starts_at)::date AS date,\n professional_id,\n COALESCE(professional_name, 'Sem profissional'::text) AS professional_name,\n (count(*))::integer AS appointment_count,\n (COALESCE(sum(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS total_revenue,\n (COALESCE(avg(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS avg_ticket,\n (0)::numeric(14,2) AS commission,\n max(updated_at) AS updated_at\n FROM public.v_appointments b\n WHERE (status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))\n GROUP BY tenant_id, ((starts_at)::date), professional_id, COALESCE(professional_name, 'Sem profissional'::text);\n\nGRANT SELECT ON TABLE public.rep_revenue_by_professional TO authenticated, service_role;\n\nCREATE OR REPLACE VIEW public.rep_revenue_by_service WITH (security_invoker = true) AS\n SELECT b.tenant_id,\n (b.starts_at)::date AS date,\n NULLIF((service_item.value ->> 'serviceId'::text), ''::text) AS service_id,\n COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text) AS service_name,\n (count(DISTINCT b.id))::integer AS quantity,\n (COALESCE(sum((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS total_revenue,\n (COALESCE(avg((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS avg_ticket,\n max(b.updated_at) AS updated_at\n FROM (public.v_appointments b\n CROSS JOIN LATERAL jsonb_array_elements(COALESCE((b.services)::jsonb, '[]'::jsonb)) service_item(value))\n WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))\n GROUP BY b.tenant_id, ((b.starts_at)::date), NULLIF((service_item.value ->> 'serviceId'::text), ''::text), COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text);\n\nGRANT SELECT ON TABLE public.rep_revenue_by_service TO authenticated, service_role;\n`\n\nexport const MIGRATION_008_A_AGENDA_DO_V1_ATRAVESSA = `-- A agenda do V1 atravessa.\n--\n-- 354.206 agendamentos e 314.310 serviços agendados do Espaço Facial não tinham\n-- para onde ir: \\`appointments\\` e \\`appointment_services\\` não estavam nem na\n-- \\`migration.allowlist\\` nem na \\`migration.excluded_tables\\`. Não era uma decisão\n-- de deixar de fora — era a ausência de decisão, e a tela de check-in abria\n-- vazia por isso.\n--\n-- ── O de-para ───────────────────────────────────────────────────────────────\n--\n-- V1 appointments → public.appointments\n-- V1 appointment_services → public.appointment_items\n--\n-- Três referências o ledger resolve, porque as três já atravessaram:\n-- \\`client_id\\` → \\`public.people\\` (123.304 linhas), \\`professional_id\\` →\n-- \\`public.people\\` (300) e \\`service_id\\` → \\`public.products\\` (122).\n--\n-- ── A unidade, que o V1 não guarda no agendamento ───────────────────────────\n--\n-- \\`public.appointments\\` do V2 quer \\`unit_id\\` — é por ela que a rede de 63\n-- unidades filtra a agenda. O V1 não tem essa coluna: ele guarda\n-- \\`service_location_id\\`, a SALA, e é a sala que sabe a unidade\n-- (\\`service_locations.unit_id\\`, um uuid que casa com \\`companies.uuid\\`).\n--\n-- A ponte é feita no extrator (\\`augment\\` do Step), não aqui, porque ela é\n-- conhecimento da ORIGEM. Medido em 19/09/2026, tenant 1:\n--\n-- agendamentos 354.206\n-- com sala que resolve a unidade 352.315 (99,5%)\n-- sem sala nenhuma 1.891\n--\n-- Os 1.891 sem sala entram com \\`unit_id\\` NULL — é o que o V1 tem, e inventar\n-- unidade para eles seria colocá-los na agenda de alguém.\n--\n-- ── O estado ────────────────────────────────────────────────────────────────\n--\n-- O vocabulário do V1 tem sete palavras; o de \\`appointment_kinds.appointment\\`\n-- tem seis. Cinco são a mesma palavra. Sobram duas, e o mapa é declarado no\n-- \\`writer_args\\` — dado, não código:\n--\n-- unmarked → cancelled desmarcado é cancelado; o V2 não tem a terceira\n-- palavra, e 'unmarked' não é um estado, é a\n-- ausência de um.\n-- waiting → confirmed a cliente chegou e espera. O V2 diz isso com\n-- \\`checked_in_at\\` (migration 003 desta corrente),\n-- que o próprio V1 já preenche — então a palavra\n-- vira 'confirmed' e o RELÓGIO carrega o resto.\n--\n-- O original fica em \\`metadata.migration.source_status\\`, como em toda tradução\n-- de vocabulário deste motor.\n--\n-- ── O que NÃO vem ───────────────────────────────────────────────────────────\n--\n-- O agendamento do V1 não tem Venda. \\`order_id\\` fica NULL, e é por isso que\n-- \\`v_appointments\\` traz \\`order_total\\`/\\`payment_status\\` vazios para o que\n-- migrou: o financeiro do V1 chega pela fatura (\\`invoices\\`), que é outra\n-- tabela e outro caminho. Costurar as duas é trabalho de reconciliação, não de\n-- transporte, e fazê-lo aqui inventaria uma Venda que não existe.\n--\n-- Idempotente.\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 1. O vocabulário fechado aprende duas palavras\n-- ─────────────────────────────────────────────────────────────────────────────\n\n-- A lista era LITERAL aqui, e literal é o que quebra a corrente: a restrição é\n-- um vocabulário fechado, e reescrevê-la por extenso APAGA toda palavra que um\n-- passo anterior tenha acrescentado depois de alguém escrever esta lista. Foi o\n-- que aconteceu com \\`fin_cash_session\\`, registrado por \\`financial/072\\`, e com\n-- \\`crm_pipeline\\`, que o spine acrescenta antes das cadeias de plugin: a linha já\n-- existia no de-para e a restaurada não a admitia —\n-- \\`check constraint \"allowlist_writer_check\" is violated by some row\\`, e o\n-- encadeamento do zero parava aqui.\n--\n-- Vira acréscimo, que é como \\`inventory/004\\` já fazia: lê a definição vigente,\n-- costura as duas palavras novas dentro dela e devolve. O que veio antes\n-- sobrevive, e o passo seguinte herda tudo.\nDO $$\nDECLARE v_def text;\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n IF v_def IS NULL OR strpos(v_def, 'appointment_item') > 0 THEN RETURN; END IF;\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''person''::text',\n '''person''::text, ''appointment''::text, ''appointment_item''::text');\nEND $$;\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 2. O writer do agendamento\n-- ─────────────────────────────────────────────────────────────────────────────\n\nCREATE OR REPLACE FUNCTION migration.upsert_appointment(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_map jsonb := coalesce(p_args -> 'status_map', '{}'::jsonb);\n v_kind text := coalesce(p_args ->> 'kind', 'appointment');\n v_src_status text := p_row ->> 'status';\n v_status text;\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_starts timestamptz;\n v_ends timestamptz;\n v_dur integer;\n v_party uuid;\n v_assignee uuid;\n v_unit uuid;\n v_existing uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an appointment needs an id');\n END IF;\n\n -- A hora é o agendamento. Sem ela não há linha no calendário, e uma linha\n -- sem hora não é um agendamento incompleto: é outra coisa.\n v_starts := (p_row ->> 'starts_at')::timestamptz;\n IF v_starts IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'an appointment needs starts_at');\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.appointment_kinds k WHERE k.kind = v_kind) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('appointment kind %L is not declared in public.appointment_kinds', v_kind));\n END IF;\n\n v_dur := nullif(p_row ->> 'duration_minutes', '')::integer;\n v_ends := v_starts + make_interval(mins => coalesce(v_dur, 0));\n\n v_status := coalesce(v_map ->> coalesce(v_src_status, ''), v_map ->> '*', v_src_status, 'scheduled');\n -- \\`appointment_kinds.statuses\\` é text[], não jsonb: o vocabulário do tipo é\n -- uma lista de palavras, e \\`= ANY (array)\\` é como se pergunta a ela.\n IF NOT EXISTS (SELECT 1 FROM public.appointment_kinds k\n WHERE k.kind = v_kind AND v_status = ANY (k.statuses)) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('status %L is not one of the statuses declared for kind %L', v_status, v_kind));\n END IF;\n v_meta := migration._deep_set(v_meta, ARRAY['migration', 'source_status'], to_jsonb(v_src_status));\n\n BEGIN v_party := (p_row ->> 'party_id')::uuid; EXCEPTION WHEN OTHERS THEN v_party := NULL; END;\n BEGIN v_assignee := (p_row ->> 'assignee_id')::uuid; EXCEPTION WHEN OTHERS THEN v_assignee := NULL; END;\n BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;\n\n SELECT tenant_id INTO v_existing FROM public.appointments WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n 'reason', format('appointment %s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing IS NULL THEN\n INSERT INTO public.appointments\n (id, tenant_id, kind, party_id, assignee_id, location_id, unit_id,\n starts_at, ends_at, status, notes, metadata, checked_in_at, created_at, updated_at)\n VALUES\n (p_id, p_tenant, v_kind, v_party, v_assignee, v_unit, v_unit,\n v_starts, v_ends, v_status, p_row ->> 'notes', v_meta,\n nullif(p_row ->> 'checked_in_at', '')::timestamptz,\n coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),\n coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));\n v_created := true;\n ELSE\n UPDATE public.appointments SET\n kind = v_kind,\n party_id = coalesce(v_party, party_id),\n assignee_id = coalesce(v_assignee, assignee_id),\n -- \\`location_id\\` é a unidade na leitura (v_appointments junta app.units\n -- por ela); \\`unit_id\\` é a unidade na RLS. As duas apontam para a mesma\n -- coisa e é isso que as mantém verdadeiras juntas.\n location_id = coalesce(v_unit, location_id),\n starts_at = v_starts,\n ends_at = v_ends,\n status = CASE WHEN p_row ? 'status' THEN v_status ELSE status END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n checked_in_at = CASE WHEN p_row ? 'checked_in_at'\n THEN nullif(p_row ->> 'checked_in_at', '')::timestamptz ELSE checked_in_at END,\n metadata = migration._merge_meta(metadata, v_meta),\n updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())\n WHERE id = p_id;\n END IF;\n\n PERFORM migration._set_unit('public.appointments', p_id, v_unit);\n\n IF v_standalone THEN\n PERFORM migration._audit(p_tenant, 'public.appointments', p_id,\n jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 3. O writer do serviço agendado\n-- ─────────────────────────────────────────────────────────────────────────────\n\nCREATE OR REPLACE FUNCTION migration.upsert_appointment_item(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_booking uuid;\n v_service uuid;\n v_name text;\n v_unit uuid;\n v_existing uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an appointment item needs an id');\n END IF;\n\n BEGIN v_booking := (p_row ->> 'booking_id')::uuid; EXCEPTION WHEN OTHERS THEN v_booking := NULL; END;\n -- Um item sem agendamento não é um item solto, é um item perdido. Recusa em\n -- vez de escrever uma linha que nenhuma tela alcança.\n IF v_booking IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'an appointment item needs the appointment it belongs to');\n END IF;\n\n BEGIN v_service := (p_row ->> 'service_id')::uuid; EXCEPTION WHEN OTHERS THEN v_service := NULL; END;\n\n -- O nome é NOT NULL no destino. A origem guarda o nome no SERVIÇO, não no\n -- item, então ele vem pela ponte do extrator; se faltar, o nome do produto\n -- que a referência resolveu serve, e só então o rótulo genérico.\n v_name := coalesce(\n nullif(p_row ->> 'name', ''),\n (SELECT pr.name FROM public.products pr WHERE pr.id = v_service),\n 'Serviço');\n\n SELECT a.unit_id INTO v_unit FROM public.appointments a WHERE a.id = v_booking;\n\n SELECT tenant_id INTO v_existing FROM public.appointment_items WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n 'reason', format('appointment item %s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing IS NULL THEN\n INSERT INTO public.appointment_items\n (id, tenant_id, booking_id, service_id, name, duration_minutes, price,\n sort_order, unit_id, metadata, created_at, updated_at)\n VALUES\n (p_id, p_tenant, v_booking, v_service, v_name,\n nullif(p_row ->> 'duration_minutes', '')::integer,\n coalesce(nullif(p_row ->> 'price', '')::numeric, 0),\n coalesce(nullif(p_row ->> 'sort_order', '')::integer, 0),\n v_unit, v_meta,\n coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),\n coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));\n v_created := true;\n ELSE\n UPDATE public.appointment_items SET\n booking_id = v_booking,\n service_id = coalesce(v_service, service_id),\n name = v_name,\n duration_minutes = CASE WHEN p_row ? 'duration_minutes'\n THEN nullif(p_row ->> 'duration_minutes', '')::integer ELSE duration_minutes END,\n unit_id = coalesce(v_unit, unit_id),\n metadata = migration._merge_meta(metadata, v_meta),\n updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN\n PERFORM migration._audit(p_tenant, 'public.appointment_items', p_id,\n jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- 4. As duas linhas do de-para\n-- ─────────────────────────────────────────────────────────────────────────────\n--\n-- A allowlist congela a linha depois de escrita — mudar o de-para é um arquivo\n-- de migration, não um UPDATE avulso, e o gatilho \\`trg_allowlist_frozen\\` é quem\n-- cobra isso. Este É o arquivo de migration, então ele destrava; \\`set_local\\`\n-- para a destrava morrer com a transação, tenha ela terminado bem ou mal.\n\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'appointments', 'public.appointments', 'agenda', 'appointment',\n jsonb_build_object(\n 'kind', 'appointment',\n 'status_map', jsonb_build_object(\n 'scheduled', 'scheduled',\n 'confirmed', 'confirmed',\n 'in_progress', 'in_progress',\n 'completed', 'completed',\n 'no_show', 'no_show',\n 'unmarked', 'cancelled',\n 'waiting', 'confirmed')),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'appointment_date', 'starts_at',\n 'duration_minutes', 'duration_minutes',\n 'status', 'status',\n 'notes', 'notes',\n 'checked_in_at', 'checked_in_at',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'client_id', 'party_id@clients',\n 'professional_id', 'assignee_id@professionals',\n 'unit_company_id', 'unit_id@companies',\n 'tenant_id', '-',\n 'checkout_confirmed_by', '-',\n 'price', 'jsonb:agenda.v1_price',\n 'is_prepaid', 'jsonb:agenda.is_prepaid',\n 'prepaid_invoice_item_id', 'jsonb:agenda.v1_prepaid_invoice_item_ref',\n 'checkout_confirmed_at', 'jsonb:agenda.checkout_confirmed_at',\n 'service_location_id', 'jsonb:agenda.v1_service_location_ref',\n 'opportunity_id', 'jsonb:crm.v1_opportunity_ref',\n 'whatsapp_sent', 'jsonb:agenda.whatsapp_sent',\n 'whatsapp_confirmed', 'jsonb:agenda.whatsapp_confirmed'),\n 'O Compromisso do V1. A unidade não vem de uma coluna: vem da SALA '\n '(service_location_id → service_locations.unit_id → companies.uuid), e a '\n 'ponte é feita no extrator. 99,5% resolvem; os 1.891 sem sala entram sem '\n 'unidade, que é o que a origem tem. Sem Venda: order_id fica NULL porque o '\n 'financeiro do V1 chega pela fatura, que é outro caminho.'\n);\n\nSELECT migration._allow(\n 'appointment_services', 'public.appointment_items', 'agenda', 'appointment_item',\n '{}'::jsonb,\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'appointment_id', 'booking_id@appointments',\n 'service_id', 'service_id@services',\n 'service_name', 'name',\n 'duration_minutes', 'duration_minutes',\n 'price', 'price',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'is_revision', 'jsonb:agenda.is_revision',\n 'revision_number', 'jsonb:agenda.revision_number',\n 'parent_journey_id', 'jsonb:agenda.v1_parent_journey_ref',\n 'added_during_execution', 'jsonb:agenda.added_during_execution',\n 'source_evaluation_execution_id','jsonb:agenda.v1_source_evaluation_ref'),\n 'O serviço dentro do Compromisso. \\`service_name\\` não existe no V1 — vem da '\n 'ponte do extrator, porque o destino exige nome e a origem guarda o nome no '\n 'serviço, não na linha. O preço é o preço praticado NAQUELE agendamento, e '\n 'por isso vem cru e não da tabela de preços.'\n);\n\nDELETE FROM migration.excluded_tables WHERE source_table IN ('appointments', 'appointment_services');\n\nRESET migration.allowlist_unlock;\n\n\n-- ── quem pode executar ────────────────────────────────────────────────────\n--\n-- Função nova nasce executável por PUBLIC, e \\`anon\\`/\\`authenticated\\` herdam\n-- dali. Estas são de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino. Só o service_role, que é quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_appointment(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_appointment(uuid, uuid, jsonb, jsonb) TO service_role;\nREVOKE ALL ON FUNCTION migration.upsert_appointment_item(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_appointment_item(uuid, uuid, jsonb, jsonb) TO service_role;\n`\n\nexport const MIGRATION_009_O_EXECUTOR_SE_ACHA_PELO_PEDIDO = `-- O executor se acha pelo pedido.\n--\n-- \\`trg_financial_seed_item_participant\\` dispara em todo INSERT de\n-- \\`public.items\\` e pergunta uma coisa só:\n--\n-- SELECT a.assignee_id, a.unit_id\n-- FROM public.appointments a\n-- WHERE a.order_id = NEW.order_id AND a.assignee_id IS NOT NULL\n-- ORDER BY a.created_at LIMIT 1\n--\n-- É a pergunta certa — \"quem executou o serviço desta linha?\" —, e é o que\n-- alimenta a comissão. O que faltava era o índice que a responde:\n-- \\`public.appointments\\` tem dez índices e **nenhum por \\`order_id\\`**.\n--\n-- ── Medido ──────────────────────────────────────────────────────────────────\n--\n-- EXPLAIN ANALYZE de um INSERT em \\`public.items\\`, tenant Espaço Facial,\n-- 20/09/2026, com 179.613 agendamentos na tabela:\n--\n-- Trigger items_financial_seed_participant: time=56.799\n-- ... os outros dezesseis gatilhos somados: time= 5.268\n-- Execution Time: 62.067 ms\n--\n-- **91% do custo de inserir um item.** E o número cresce com a agenda: o mesmo\n-- INSERT vai custar o dobro quando os 354.208 compromissos do V1 tiverem\n-- entrado, porque a varredura é da tabela inteira.\n--\n-- ── Não é só da migração ────────────────────────────────────────────────────\n--\n-- Vale registrar, porque o índice se paga duas vezes: este gatilho roda toda\n-- vez que alguém acrescenta uma linha a uma venda no app — a comanda, o\n-- carrinho, o atendimento que ganha um produto no fim. Numa rede com agenda\n-- cheia, cada item somava dezenas de milissegundos de varredura que ninguém\n-- pediu. O porte só foi quem gritou primeiro: 220.462 itens a 62 ms são quatro\n-- horas.\n--\n-- ── Por que PARCIAL ─────────────────────────────────────────────────────────\n--\n-- O compromisso do V1 não tem Venda: ele chega com \\`order_id\\` NULL, e são\n-- centenas de milhares assim. Um índice completo carregaria todos eles para\n-- responder sobre os poucos que têm pedido. O parcial indexa só as linhas que\n-- a consulta pode encontrar — e o planejador o usa porque o predicado\n-- (\\`order_id = …\\`) implica a condição do índice.\n--\n-- \\`CONCURRENTLY\\` porque o app está no ar: a forma normal tranca a escrita em\n-- \\`appointments\\` enquanto constrói, e uma recepção marcando horário não tem\n-- por que esperar por um índice.\n--\n-- Idempotente.\n\n-- Sem CONCURRENTLY: é o único arquivo do repositório que o usava, e ele não\n-- roda dentro de transação — o encadeamento aplica cada migração numa. A\n-- tabela é pequena o bastante para o bloqueio curto de um CREATE INDEX comum.\nCREATE INDEX IF NOT EXISTS appointments_order_idx\n ON public.appointments (order_id)\n WHERE order_id IS NOT NULL;\n\nCOMMENT ON INDEX public.appointments_order_idx IS\n 'O caminho de \"quem executou o serviço desta linha da venda\", que '\n 'trg_financial_seed_item_participant percorre em todo INSERT de items. Sem '\n 'ele a pergunta era varredura da agenda inteira: 56,8 ms dos 62 ms de '\n 'inserir um item, medido com 179.613 compromissos.';\n`\n\nexport const MIGRATION_010_A_GRADE_DO_V1_ATRAVESSA = `-- A grade do V1 atravessa.\n--\n-- 008 trouxe o COMPROMISSO (353.895) e o serviço agendado (314.051). Faltava o\n-- que diz QUANDO se pode marcar: a grade. \\`public.schedules\\` existe desde o\n-- baseline e estava com ZERO linhas no Espaço Facial — por isso\n-- \\`get_available_slots\\` não ofertava horário nenhum, em nenhuma das 63\n-- unidades.\n--\n-- ── O de-para ───────────────────────────────────────────────────────────────\n--\n-- V1 service_locations → public.plg_agenda_resources (a sala)\n-- V1 schedule_grid_blocks → public.schedules kind='working_hours'\n-- V1 schedule_blocks → public.schedules kind='block'\n-- V1 schedule_grids → não vira linha: desce inteira para o bloco\n--\n-- Medido em 21/09/2026 contra o V1 de produção (somente leitura), tenant 1:\n--\n-- service_locations 223 (219 ativas, 127 unidades)\n-- schedule_grids 482 (271 standard · 211 exception)\n-- schedule_grid_blocks 1.331 (nenhum órfão de sala ou profissional)\n-- schedule_blocks 8\n--\n-- ── Por que a grade não vira linha ──────────────────────────────────────────\n--\n-- A grade do V1 é (profissional, vigência, tipo, intervalo de slot) e os BLOCOS\n-- são (weekday, faixa de horário, sala). O destino é um arquétipo de linha\n-- única: \\`schedules\\` descreve UMA janela. Então os atributos da grade descem\n-- para cada bloco, e a grade não tem linha própria.\n--\n-- A alternativa era expandir cada bloco em uma linha por data. Dá 44.493 linhas\n-- contra 1.331, não representa a grade ETERNA (248 das 482 têm valid_to nulo), e\n-- mata o espelho D-1: um UPDATE na grade viraria milhares de INSERT/DELETE em\n-- vez de um UPDATE.\n--\n-- ── A sala, e por que ela NÃO se chama location ─────────────────────────────\n--\n-- \\`service_locations\\` do V1 é a SALA. \\`plg_agenda_locations\\` seria a tradução\n-- literal e seria errada: neste SDK \\`location\\` já significa FILIAL —\n-- \\`packages/core/src/data/archetype.ts\\` declara\n-- \\`location: { table: 'v_units', fkColumn: 'location_id' }\\`, \\`scoped_resources\\`\n-- usa \\`location_id\\` como a coluna de unidade de \\`appointments\\` e \\`schedules\\`, e\n-- 352.005 compromissos migrados já gravaram a unidade ali.\n--\n-- \\`resources\\` é o nome que Fresha, Mindbody, Booksy, Square e o Google Calendar\n-- dão para \"sala, cadeira, maca, aparelho\" — uma tabela para tudo que se reserva\n-- e não é gente.\n--\n-- O outro padrão da indústria é o do FHIR: \\`Location\\` com \\`physicalType\\`\n-- (room/bed/wing) e \\`partOf\\` aninhando sala dentro de prédio, numa tabela só.\n-- Não serve aqui porque a unidade não é uma linha vizinha: é \\`app.units\\`, a\n-- cerca da franquia. A migration 091 já tentou apontar \\`service_locations\\` para\n-- \\`app.units\\` e foi revertida — o motivo está escrito na \\`excluded_tables\\`:\n-- transformaria cada sala de atendimento numa filial.\n--\n-- ── O que a função de disponibilidade não sabia ─────────────────────────────\n--\n-- \\`agenda_working_windows\\` tinha precedência de DIA: qualquer linha com\n-- \\`specific_date\\` apagava as semanais daquele profissional. O V1 tem três regras\n-- que isso não cobre, e todas as três mudam o horário que a tela oferta:\n--\n-- VIGÊNCIA a grade vale de valid_from a valid_to. Sem ela, grade encerrada\n-- em janeiro continua ofertando e grade que começa em 2027 já\n-- oferta hoje. 234 das 482 grades têm vigência fechada.\n--\n-- PRECEDÊNCIA durante a vigência de uma grade \\`exception\\`, cada weekday que\n-- ela define SUBSTITUI a padrão naquele dia — e só naquele. Os\n-- weekdays que a exceção não define seguem a padrão.\n--\n-- PARIDADE week_interval: 0 = toda semana; N = atende uma e pula N,\n-- ancorado na PRIMEIRA ocorrência daquele weekday dentro da\n-- vigência. E quando a paridade da exceção pula a semana, o dia\n-- fica FECHADO — não volta para a padrão.\n--\n-- A fonte é o motor puro do V1, compartilhado verbatim entre o frontend e as\n-- edge functions: ~/dev/beautyplace/supabase/functions/_shared/schedule-engine.ts\n-- (\\`resolveEffectiveWindows\\`, \\`blockActiveOnDate\\`).\n--\n-- O BLOQUEIO não entra como janela negativa: no V1 ele é tempo OCUPADO na hora\n-- de gerar o slot, igual a um agendamento (\\`bloqueiosAsBusy\\`). Aqui é a mesma\n-- coisa — \\`get_available_slots\\` passa a subtrair \\`agenda_busy_blocks\\`, do mesmo\n-- jeito que já subtraía \\`appointments\\`.\n--\n-- Idempotente.\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 1. A sala\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_agenda_resources (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n -- A sala é SEMPRE de uma unidade: 223 de 223 no V1 têm unit_id. NOT NULL é\n -- a trava da franquia — uma sala sem unidade apareceria na agenda de todas.\n unit_id uuid NOT NULL,\n name text NOT NULL,\n -- Sala, cadeira, maca, aparelho. Vocabulário DO PLUGIN, num CHECK: \\`kind\\`\n -- com registry é contrato de arquétipo, e \\`plg_*\\` não é arquétipo.\n kind text DEFAULT 'room'::text NOT NULL,\n -- \\`service_locations.max_simultaneous_clients\\` — 1 ou 2 no V1. É o que\n -- permite duas clientes na mesma sala ao mesmo tempo.\n capacity integer DEFAULT 1 NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n sort_order integer DEFAULT 0 NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n owner_id uuid,\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_agenda_resources_kind_check\n CHECK (kind = ANY (ARRAY['room'::text, 'chair'::text, 'equipment'::text, 'other'::text])),\n CONSTRAINT plg_agenda_resources_capacity_check CHECK (capacity >= 1)\n);\n\nCOMMENT ON TABLE public.plg_agenda_resources IS\n 'A sala (ou cadeira, maca, aparelho) onde o atendimento acontece, DENTRO de uma unidade. V1: service_locations. Não se chama location porque location_id, neste SDK, é a unidade.';\nCOMMENT ON COLUMN public.plg_agenda_resources.capacity IS\n 'V1: service_locations.max_simultaneous_clients — quantas pessoas cabem ao mesmo tempo.';\n\nALTER TABLE public.plg_agenda_resources FORCE ROW LEVEL SECURITY;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_agenda_resources'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_agenda_resources ADD CONSTRAINT plg_agenda_resources_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_resources_tenant_id_id_key') THEN\n ALTER TABLE ONLY public.plg_agenda_resources\n ADD CONSTRAINT plg_agenda_resources_tenant_id_id_key UNIQUE (tenant_id, id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_resources_tenant_fkey') THEN\n ALTER TABLE ONLY public.plg_agenda_resources\n ADD CONSTRAINT plg_agenda_resources_tenant_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\n -- A FK composta, e não só \\`unit_id\\`: é ela que impede a sala de uma casa\n -- apontar para a filial de outra.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_resources_unit_fkey') THEN\n ALTER TABLE ONLY public.plg_agenda_resources\n ADD CONSTRAINT plg_agenda_resources_unit_fkey FOREIGN KEY (tenant_id, unit_id)\n REFERENCES app.units(tenant_id, id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS idx_plg_agenda_resources_tenant_unit\n ON public.plg_agenda_resources USING btree (tenant_id, unit_id) WHERE is_active;\n\nDROP TRIGGER IF EXISTS plg_agenda_resources_updated_at ON public.plg_agenda_resources;\nCREATE TRIGGER plg_agenda_resources_updated_at\n BEFORE UPDATE ON public.plg_agenda_resources\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_agenda_resources ENABLE ROW LEVEL SECURITY;\n\n-- Escopo de UNIDADE, sem escopo de dono: uma sala não pertence a uma pessoa.\n-- Quem enxerga a agenda da filial enxerga as salas dela, e nenhuma outra.\nDROP POLICY IF EXISTS plg_agenda_resources_authz_select ON public.plg_agenda_resources;\nCREATE POLICY plg_agenda_resources_authz_select ON public.plg_agenda_resources\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_resources.unit_id))\n AND (SELECT app.has_permission('agenda.read', plg_agenda_resources.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_resources_authz_insert ON public.plg_agenda_resources;\nCREATE POLICY plg_agenda_resources_authz_insert ON public.plg_agenda_resources\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_resources.unit_id))\n AND (SELECT app.has_permission('agenda.create', plg_agenda_resources.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_resources_authz_update ON public.plg_agenda_resources;\nCREATE POLICY plg_agenda_resources_authz_update ON public.plg_agenda_resources\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_resources.unit_id))\n AND (SELECT app.has_permission('agenda.edit', plg_agenda_resources.unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_resources.unit_id))\n AND (SELECT app.has_permission('agenda.edit', plg_agenda_resources.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_resources_authz_delete ON public.plg_agenda_resources;\nCREATE POLICY plg_agenda_resources_authz_delete ON public.plg_agenda_resources\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_resources.unit_id))\n AND (SELECT app.has_permission('agenda.delete', plg_agenda_resources.unit_id)));\n\nGRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.plg_agenda_resources TO authenticated;\nGRANT ALL ON TABLE public.plg_agenda_resources TO service_role;\n\n-- \\`unit_column\\` é \\`unit_id\\` aqui, e \\`location_id\\` em appointments/schedules.\n-- Não é inconsistência: aquelas duas carregam \\`location_id\\` desde a 004 e o\n-- SDK inteiro lê por ele; uma tabela NOVA não precisa herdar o nome ambíguo.\nINSERT INTO public.scoped_resources (resource_table, unit_column, owner_column, shareable, default_visibility, label)\nVALUES ('plg_agenda_resources', 'unit_id', 'owner_id', false, 'unit', 'Salas')\nON CONFLICT (resource_table) DO UPDATE\n SET unit_column = EXCLUDED.unit_column, default_visibility = EXCLUDED.default_visibility, label = EXCLUDED.label;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 2. O que a grade tem e o arquétipo não tinha\n-- ═══════════════════════════════════════════════════════════════════════════\n\nALTER TABLE public.schedules ADD COLUMN IF NOT EXISTS valid_from date;\nALTER TABLE public.schedules ADD COLUMN IF NOT EXISTS valid_to date;\nALTER TABLE public.schedules ADD COLUMN IF NOT EXISTS grid_kind text;\nALTER TABLE public.schedules ADD COLUMN IF NOT EXISTS week_interval smallint DEFAULT 0 NOT NULL;\nALTER TABLE public.schedules ADD COLUMN IF NOT EXISTS slot_interval_minutes integer;\nALTER TABLE public.schedules ADD COLUMN IF NOT EXISTS resource_id uuid;\nALTER TABLE public.appointments ADD COLUMN IF NOT EXISTS resource_id uuid;\n\nCOMMENT ON COLUMN public.schedules.valid_from IS 'V1: schedule_grids.valid_from — a janela só vale a partir desta data.';\nCOMMENT ON COLUMN public.schedules.valid_to IS 'V1: schedule_grids.valid_to — NULL = eterna (248 das 482 grades do V1).';\nCOMMENT ON COLUMN public.schedules.grid_kind IS 'V1: schedule_grids.kind — standard | exception. Durante a vigência, a exceção substitui a padrão naquele weekday, e só naquele.';\nCOMMENT ON COLUMN public.schedules.week_interval IS 'V1: 0 = toda semana; N = atende uma e pula N, ancorado na primeira ocorrência do weekday dentro da vigência.';\nCOMMENT ON COLUMN public.schedules.slot_interval_minutes IS 'V1: schedule_grids.slot_interval_minutes — o passo de oferta DESTA grade (5/15/30), não o do parâmetro da chamada.';\nCOMMENT ON COLUMN public.schedules.resource_id IS 'V1: schedule_grid_blocks.service_location_id — a sala em que o profissional atende nesta janela.';\nCOMMENT ON COLUMN public.appointments.resource_id IS 'V1: appointments.service_location_id — a sala do atendimento.';\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'schedules_grid_kind_check') THEN\n ALTER TABLE public.schedules ADD CONSTRAINT schedules_grid_kind_check\n CHECK (grid_kind IS NULL OR grid_kind = ANY (ARRAY['standard'::text, 'exception'::text]));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'schedules_week_interval_check') THEN\n ALTER TABLE public.schedules ADD CONSTRAINT schedules_week_interval_check\n CHECK (week_interval >= 0 AND week_interval <= 52);\n END IF;\n -- Vigência invertida não é uma grade estranha, é uma grade que não existe.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'schedules_validity_check') THEN\n ALTER TABLE public.schedules ADD CONSTRAINT schedules_validity_check\n CHECK (valid_from IS NULL OR valid_to IS NULL OR valid_from <= valid_to);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'schedules_resource_fkey') THEN\n ALTER TABLE public.schedules ADD CONSTRAINT schedules_resource_fkey\n FOREIGN KEY (resource_id) REFERENCES public.plg_agenda_resources(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'appointments_resource_fkey') THEN\n ALTER TABLE public.appointments ADD CONSTRAINT appointments_resource_fkey\n FOREIGN KEY (resource_id) REFERENCES public.plg_agenda_resources(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\n-- A consulta que a agenda faz o dia inteiro: \"quem trabalha neste weekday,\n-- nesta casa, nesta data\".\nCREATE INDEX IF NOT EXISTS idx_schedules_tenant_kind_dow\n ON public.schedules USING btree (tenant_id, kind, day_of_week)\n WHERE specific_date IS NULL;\nCREATE INDEX IF NOT EXISTS idx_schedules_tenant_kind_date\n ON public.schedules USING btree (tenant_id, kind, specific_date)\n WHERE specific_date IS NOT NULL;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 3. A paridade de semana\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Espelho de \\`blockActiveOnDate\\` do motor do V1. A âncora é a primeira\n-- ocorrência daquele weekday DENTRO da vigência: quando \\`valid_from\\` cai depois\n-- desse dia na própria semana, a âncora desliza para a semana seguinte, e por\n-- isso o \\`-1\\`. Sem ele a primeira semana contaria como \"semana sim\" ou \"semana\n-- não\" conforme o dia em que a vigência começou, que é o defeito que faria a\n-- quinzena inverter.\nCREATE OR REPLACE FUNCTION public.agenda_week_parity_active(\n p_valid_from date, p_date date, p_week_interval smallint\n) RETURNS boolean\nLANGUAGE sql IMMUTABLE SET search_path TO ''\nAS $function$\n SELECT CASE\n WHEN coalesce(p_week_interval, 0) <= 0 THEN true\n WHEN p_valid_from IS NULL THEN true\n ELSE (\n SELECT w.wi >= 0 AND (w.wi % (p_week_interval + 1)) = 0\n FROM (SELECT\n ((p_date - extract(dow from p_date)::int)\n - (p_valid_from - extract(dow from p_valid_from)::int)) / 7\n - CASE WHEN extract(dow from p_date) < extract(dow from p_valid_from)\n THEN 1 ELSE 0 END AS wi) w)\n END\n$function$;\n\nCOMMENT ON FUNCTION public.agenda_week_parity_active(date, date, smallint) IS\n 'A semana é \"semana sim\"? Espelho de blockActiveOnDate do motor de grades do V1.';\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 4. As janelas efetivas do dia\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- DROP e não REPLACE: o retorno ganha \\`resource_id\\` e \\`slot_interval_minutes\\`, e\n-- \\`CREATE OR REPLACE FUNCTION\\` não muda tipo de retorno. Os quatro chamadores\n-- são corpos plpgsql que nomeiam colunas (\\`w.assignee_id\\`, \\`w.win_start\\`,\n-- \\`w.win_end\\`), então não são dependências do catálogo e continuam válidos.\n\nDROP FUNCTION IF EXISTS public.agenda_working_windows(uuid, date, uuid);\n\nCREATE FUNCTION public.agenda_working_windows(\n p_tenant_id uuid, p_date date, p_assignee_id uuid DEFAULT NULL::uuid\n) RETURNS TABLE(assignee_id uuid, win_start time without time zone, win_end time without time zone,\n resource_id uuid, slot_interval_minutes integer)\nLANGUAGE sql STABLE SET search_path TO 'public'\nAS $function$\n WITH vigente AS (\n -- Vigência primeiro: o que não vale hoje não participa de decisão nenhuma,\n -- nem como exceção que fecha o dia.\n SELECT sc.assignee_id, sc.starts_at, sc.ends_at, sc.is_active, sc.specific_date,\n sc.day_of_week, sc.grid_kind, sc.week_interval, sc.valid_from,\n sc.resource_id, sc.slot_interval_minutes\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n AND (sc.valid_from IS NULL OR sc.valid_from <= p_date)\n AND (sc.valid_to IS NULL OR p_date <= sc.valid_to)\n ),\n -- A exceção de DIA: a linha com specific_date nesta data. Precedência máxima,\n -- e conta mesmo INATIVA — é assim que o \"dia de folga\" apaga a semana.\n exc_dia AS (\n SELECT v.* FROM vigente v WHERE v.specific_date = p_date\n ),\n -- As semanais deste weekday, já com a paridade aplicada.\n semanal AS (\n SELECT v.* FROM vigente v\n WHERE v.specific_date IS NULL\n AND v.is_active\n AND v.day_of_week = extract(dow from p_date)::smallint\n AND public.agenda_week_parity_active(v.valid_from, p_date, v.week_interval)\n ),\n -- Quem tem grade de EXCEÇÃO definindo este weekday — ainda que a paridade\n -- tenha pulado a semana. É por isso que este CTE não filtra por paridade: no\n -- V1, exceção que pula a semana FECHA o dia, não devolve a padrão.\n tem_excecao AS (\n SELECT DISTINCT v.assignee_id FROM vigente v\n WHERE v.grid_kind = 'exception'\n AND v.specific_date IS NULL\n AND v.is_active\n AND v.day_of_week = extract(dow from p_date)::smallint\n ),\n grade AS (\n SELECT s.assignee_id, s.starts_at, s.ends_at, s.resource_id, s.slot_interval_minutes\n FROM semanal s\n WHERE s.grid_kind = 'exception'\n UNION ALL\n SELECT s.assignee_id, s.starts_at, s.ends_at, s.resource_id, s.slot_interval_minutes\n FROM semanal s\n WHERE coalesce(s.grid_kind, 'standard') <> 'exception'\n AND NOT EXISTS (SELECT 1 FROM tem_excecao t\n WHERE t.assignee_id IS NOT DISTINCT FROM s.assignee_id)\n )\n SELECT e.assignee_id, e.starts_at, e.ends_at, e.resource_id, e.slot_interval_minutes\n FROM exc_dia e WHERE e.is_active\n UNION ALL\n SELECT g.assignee_id, g.starts_at, g.ends_at, g.resource_id, g.slot_interval_minutes\n FROM grade g\n WHERE NOT EXISTS (SELECT 1 FROM exc_dia e\n WHERE e.assignee_id IS NOT DISTINCT FROM g.assignee_id)\n$function$;\n\nCOMMENT ON FUNCTION public.agenda_working_windows(uuid, date, uuid) IS\n 'Janelas de atendimento efetivas na data: vigência, precedência da exceção por weekday e paridade de semana. Espelho de resolveEffectiveWindows do motor do V1.';\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 5. O bloqueio é tempo ocupado\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Não é janela negativa: no V1 o bloqueio entra como BUSY na geração do slot,\n-- igual a um agendamento. \\`assignee_id\\` nulo é bloqueio da casa inteira — um\n-- feriado — e o V1 tem exatamente essa forma.\nCREATE OR REPLACE FUNCTION public.agenda_busy_blocks(\n p_tenant_id uuid, p_date date, p_assignee_id uuid DEFAULT NULL::uuid\n) RETURNS TABLE(assignee_id uuid, busy_start time without time zone, busy_end time without time zone)\nLANGUAGE sql STABLE SET search_path TO 'public'\nAS $function$\n SELECT sc.assignee_id, sc.starts_at, sc.ends_at\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'block'\n AND sc.is_active\n AND (p_assignee_id IS NULL OR sc.assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n AND (sc.valid_from IS NULL OR sc.valid_from <= p_date)\n AND (sc.valid_to IS NULL OR p_date <= sc.valid_to)\n AND (\n -- ocorrência única\n sc.specific_date = p_date\n -- faixa de datas sem weekday: bloqueia todo dia da faixa\n OR (sc.specific_date IS NULL AND sc.day_of_week IS NULL)\n -- recorrente por weekday, com a mesma paridade da grade\n OR (sc.specific_date IS NULL\n AND sc.day_of_week = extract(dow from p_date)::smallint\n AND public.agenda_week_parity_active(sc.valid_from, p_date, sc.week_interval))\n )\n$function$;\n\nCOMMENT ON FUNCTION public.agenda_busy_blocks(uuid, date, uuid) IS\n 'Tempo ocupado por bloqueio (almoço, feriado, compromisso) na data. V1: schedule_blocks.';\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 6. A oferta de horário passa a obedecer à grade\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Duas mudanças. O passo sai da JANELA quando ela tem um — o V1 guarda o\n-- intervalo na grade (5, 15 ou 30 conforme o profissional), e 34 das 482 grades\n-- não são de 30; ofertar de 30 em 30 para elas é ofertar horário que a casa não\n-- atende. E o bloqueio passa a ocupar, do mesmo jeito que o agendamento já\n-- ocupava.\nCREATE OR REPLACE FUNCTION public.get_available_slots(\n p_tenant_id uuid, p_date date, p_duration_minutes integer,\n p_assignee_id uuid DEFAULT NULL::uuid, p_slot_interval integer DEFAULT 30\n) RETURNS TABLE(slot_start timestamp with time zone, slot_end timestamp with time zone)\nLANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public'\nAS $function$\nDECLARE\n v_tz text;\nBEGIN\n -- tight validation: this function is anon-callable\n IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN\n RAISE EXCEPTION 'invalid duration';\n END IF;\n IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN\n RAISE EXCEPTION 'invalid interval';\n END IF;\n IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN\n RETURN;\n END IF;\n\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM tenants t WHERE t.id = p_tenant_id;\n IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error\n\n RETURN QUERY\n WITH day_schedules AS (\n SELECT w.assignee_id, w.win_start, w.win_end,\n -- O passo da grade, com o parâmetro como queda. Preso à mesma faixa\n -- que a validação de cima cobra do parâmetro: uma grade com passo\n -- absurdo não pode virar um generate_series infinito numa função\n -- que o anônimo chama.\n CASE WHEN w.slot_interval_minutes BETWEEN 5 AND 240\n THEN w.slot_interval_minutes ELSE p_slot_interval END AS step\n FROM agenda_working_windows(p_tenant_id, p_date, p_assignee_id) w\n ),\n candidate AS (\n SELECT\n ds.assignee_id,\n gs AS s_start,\n gs + make_interval(mins => p_duration_minutes) AS s_end\n FROM day_schedules ds\n CROSS JOIN LATERAL generate_series(\n (p_date + ds.win_start) AT TIME ZONE v_tz,\n ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),\n make_interval(mins => ds.step)\n ) AS gs\n )\n SELECT DISTINCT c.s_start, c.s_end\n FROM candidate c\n WHERE NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n -- SMART SYNC (gcal 002): this professional's own bookings OR any\n -- tenant-wide BLOCK (assignee_id IS NULL AND kind='block') busy the slot.\n AND (b.assignee_id = c.assignee_id\n OR (b.assignee_id IS NULL AND b.kind = 'block'))\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < c.s_end\n AND COALESCE(b.ends_at, b.starts_at) > c.s_start\n )\n -- O bloqueio da grade do V1 (almoço, feriado). \\`assignee_id\\` nulo é da casa.\n AND NOT EXISTS (\n SELECT 1 FROM agenda_busy_blocks(p_tenant_id, p_date, p_assignee_id) bb\n WHERE (bb.assignee_id IS NULL OR bb.assignee_id = c.assignee_id)\n AND (p_date + bb.busy_start) AT TIME ZONE v_tz < c.s_end\n AND (p_date + bb.busy_end) AT TIME ZONE v_tz > c.s_start\n )\n ORDER BY 1;\nEND;\n$function$;\n\nREVOKE ALL ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid) TO authenticated, service_role;\nREVOKE ALL ON FUNCTION public.agenda_busy_blocks(uuid, date, uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agenda_busy_blocks(uuid, date, uuid) TO authenticated, service_role;\nREVOKE ALL ON FUNCTION public.agenda_week_parity_active(date, date, smallint) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agenda_week_parity_active(date, date, smallint) TO authenticated, anon, service_role;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 7. O vocabulário fechado aprende duas palavras\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Por ACRÉSCIMO, lendo a definição vigente: a lista literal apaga toda palavra\n-- que um passo anterior tenha acrescentado depois de alguém escrever a lista. É\n-- a mesma armadilha registrada em financial/072 e agenda/008.\nDO $$\nDECLARE v_def text;\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n IF v_def IS NULL OR strpos(v_def, 'agenda_schedule') > 0 THEN RETURN; END IF;\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''person''::text',\n '''person''::text, ''agenda_resource''::text, ''agenda_schedule''::text');\nEND $$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 8. O writer da sala\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE OR REPLACE FUNCTION migration.upsert_agenda_resource(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_unit uuid;\n v_name text;\n v_cap integer;\n v_existing uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a resource needs an id');\n END IF;\n\n BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;\n -- Sala sem unidade não é sala incompleta: é sala que apareceria na agenda de\n -- todas as 63 filiais. A coluna é NOT NULL por isso, e a recusa é aqui para\n -- a linha ir para a quarentena com o motivo escrito em vez de estourar.\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'a resource needs the unit it lives in');\n END IF;\n\n v_name := nullif(trim(coalesce(p_row ->> 'name', '')), '');\n IF v_name IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a resource needs a name');\n END IF;\n\n -- \\`max_simultaneous_clients\\` vem 1 ou 2 no V1; zero ou nulo vira 1, porque\n -- capacidade zero descreveria uma sala onde ninguém entra.\n v_cap := greatest(coalesce(nullif(p_row ->> 'capacity', '')::integer, 1), 1);\n\n SELECT tenant_id INTO v_existing FROM public.plg_agenda_resources WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n 'reason', format('resource %s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing IS NULL THEN\n INSERT INTO public.plg_agenda_resources\n (id, tenant_id, unit_id, name, kind, capacity, is_active, sort_order, metadata, created_at, updated_at)\n VALUES\n (p_id, p_tenant, v_unit, v_name, coalesce(p_args ->> 'kind', 'room'), v_cap,\n coalesce((p_row ->> 'is_active')::boolean, true),\n coalesce(nullif(p_row ->> 'sort_order', '')::integer, 0), v_meta,\n coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),\n coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));\n v_created := true;\n ELSE\n UPDATE public.plg_agenda_resources SET\n unit_id = v_unit,\n name = v_name,\n capacity = v_cap,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n metadata = migration._merge_meta(metadata, v_meta),\n updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN\n PERFORM migration._audit(p_tenant, 'public.plg_agenda_resources', p_id,\n jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 9. O writer da janela — grade e bloqueio pela mesma porta\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Duas tabelas do V1 e um destino só, porque o que as duas descrevem é a mesma\n-- coisa: uma faixa de horário num dia, com vigência. O que muda é o sinal —\n-- \\`working_hours\\` abre, \\`block\\` fecha — e isso é o \\`kind\\`, que vem do\n-- \\`writer_args\\`.\n\nCREATE OR REPLACE FUNCTION migration.upsert_agenda_schedule(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_kind text := coalesce(p_args ->> 'kind', 'working_hours');\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_assignee uuid;\n v_unit uuid;\n v_resource uuid;\n v_starts time;\n v_ends time;\n v_dow smallint;\n v_specific date;\n v_from date;\n v_to date;\n v_grid text;\n v_weekdays jsonb;\n v_existing uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a schedule needs an id');\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.schedule_kinds k WHERE k.kind = v_kind) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('schedule kind %L is not declared in public.schedule_kinds', v_kind));\n END IF;\n\n BEGIN v_assignee := (p_row ->> 'assignee_id')::uuid; EXCEPTION WHEN OTHERS THEN v_assignee := NULL; END;\n BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;\n BEGIN v_resource := (p_row ->> 'resource_id')::uuid; EXCEPTION WHEN OTHERS THEN v_resource := NULL; END;\n\n -- Uma janela sem dono não oferta horário de ninguém. O bloqueio da CASA é\n -- outra coisa e chega com assignee nulo de propósito — por isso a recusa só\n -- vale para a grade.\n IF v_assignee IS NULL AND v_kind = 'working_hours' THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'a working-hours window needs the professional it belongs to');\n END IF;\n\n -- Recorrência por lista de dias: uma linha do V1 viraria N no destino, e o\n -- ledger é 1:1. Zero linhas assim no Espaço Facial; quando existir, a forma\n -- certa é o extrator explodir a lista, não o writer inventar um dia.\n v_weekdays := p_row -> 'v1_weekdays';\n IF v_weekdays IS NOT NULL AND jsonb_typeof(v_weekdays) = 'array' AND jsonb_array_length(v_weekdays) > 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', 'a block recurring by weekday list needs one destination row per weekday; the extractor has to split it');\n END IF;\n\n -- O dia inteiro é uma faixa como outra qualquer no destino. 00:00–23:59 e não\n -- 24:00 porque \\`time\\` não tem 24:00, e um minuto de folga à meia-noite não\n -- muda nenhuma decisão de agenda.\n IF coalesce((p_row ->> 'all_day')::boolean, false) THEN\n v_starts := '00:00'::time; v_ends := '23:59'::time;\n ELSE\n BEGIN v_starts := (p_row ->> 'starts_at')::time; EXCEPTION WHEN OTHERS THEN v_starts := NULL; END;\n BEGIN v_ends := (p_row ->> 'ends_at')::time; EXCEPTION WHEN OTHERS THEN v_ends := NULL; END;\n END IF;\n IF v_starts IS NULL OR v_ends IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a schedule needs a start and an end');\n END IF;\n IF v_ends <= v_starts THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('a schedule ends before it starts: %s–%s', v_starts, v_ends));\n END IF;\n\n BEGIN v_dow := (p_row ->> 'day_of_week')::smallint; EXCEPTION WHEN OTHERS THEN v_dow := NULL; END;\n BEGIN v_specific := (p_row ->> 'specific_date')::date; EXCEPTION WHEN OTHERS THEN v_specific := NULL; END;\n BEGIN v_from := (p_row ->> 'valid_from')::date; EXCEPTION WHEN OTHERS THEN v_from := NULL; END;\n BEGIN v_to := (p_row ->> 'valid_to')::date; EXCEPTION WHEN OTHERS THEN v_to := NULL; END;\n\n IF v_dow IS NULL AND v_specific IS NULL AND v_kind = 'working_hours' THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', 'a working-hours window needs a weekday or a date');\n END IF;\n IF v_dow IS NOT NULL AND v_dow NOT BETWEEN 0 AND 6 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('weekday %s is outside 0..6', v_dow));\n END IF;\n -- Vigência invertida é recusada aqui e não pela CHECK, para a linha ir para a\n -- quarentena com o motivo em vez de derrubar o lote inteiro.\n IF v_from IS NOT NULL AND v_to IS NOT NULL AND v_from > v_to THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('validity ends before it begins: %s–%s', v_from, v_to));\n END IF;\n\n v_grid := nullif(p_row ->> 'grid_kind', '');\n IF v_grid IS NOT NULL AND v_grid NOT IN ('standard', 'exception') THEN\n v_meta := migration._deep_set(v_meta, ARRAY['migration', 'source_grid_kind'], to_jsonb(v_grid));\n v_grid := 'standard';\n END IF;\n\n -- A sala sabe a unidade quando o extrator não soube dizê-la.\n IF v_unit IS NULL AND v_resource IS NOT NULL THEN\n SELECT r.unit_id INTO v_unit FROM public.plg_agenda_resources r\n WHERE r.id = v_resource AND r.tenant_id = p_tenant;\n END IF;\n\n SELECT tenant_id INTO v_existing FROM public.schedules WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n 'reason', format('schedule %s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing IS NULL THEN\n INSERT INTO public.schedules\n (id, tenant_id, kind, assignee_id, location_id, unit_id, resource_id,\n day_of_week, specific_date, starts_at, ends_at, is_active,\n valid_from, valid_to, grid_kind, week_interval, slot_interval_minutes,\n metadata, created_at, updated_at)\n VALUES\n -- \\`location_id\\` E \\`unit_id\\`: a primeira é a coluna de unidade que\n -- \\`scoped_resources\\` declara para \\`schedules\\`, a segunda é a da RLS. As\n -- duas apontam para a mesma filial, e é isso que as mantém verdadeiras.\n (p_id, p_tenant, v_kind, v_assignee, v_unit, v_unit, v_resource,\n v_dow, v_specific, v_starts, v_ends,\n coalesce((p_row ->> 'is_active')::boolean, true),\n v_from, v_to, v_grid,\n coalesce(nullif(p_row ->> 'week_interval', '')::smallint, 0),\n nullif(p_row ->> 'slot_interval_minutes', '')::integer,\n v_meta,\n coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),\n coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));\n v_created := true;\n ELSE\n UPDATE public.schedules SET\n kind = v_kind,\n assignee_id = coalesce(v_assignee, assignee_id),\n location_id = coalesce(v_unit, location_id),\n unit_id = coalesce(v_unit, unit_id),\n resource_id = coalesce(v_resource, resource_id),\n day_of_week = v_dow,\n specific_date = v_specific,\n starts_at = v_starts,\n ends_at = v_ends,\n is_active = coalesce((p_row ->> 'is_active')::boolean, is_active),\n valid_from = v_from,\n valid_to = v_to,\n grid_kind = v_grid,\n week_interval = coalesce(nullif(p_row ->> 'week_interval', '')::smallint, 0),\n slot_interval_minutes = nullif(p_row ->> 'slot_interval_minutes', '')::integer,\n metadata = migration._merge_meta(metadata, v_meta),\n updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())\n WHERE id = p_id;\n END IF;\n\n PERFORM migration._set_unit('public.schedules', p_id, v_unit);\n\n IF v_standalone THEN\n PERFORM migration._audit(p_tenant, 'public.schedules', p_id,\n jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 10. As três linhas do de-para\n-- ═══════════════════════════════════════════════════════════════════════════\n\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'service_locations', 'public.plg_agenda_resources', 'agenda', 'agenda_resource',\n jsonb_build_object('kind', 'room'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'name', 'name',\n 'active', 'is_active',\n 'max_simultaneous_clients', 'capacity',\n 'unit_company_id', 'unit_id@companies',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'unit_id', '-',\n 'id', 'jsonb:agenda.v1_service_location_ref',\n 'group_id', 'jsonb:agenda.v1_location_group_ref'),\n 'A SALA, não a filial. \\`service_locations.unit_id\\` é um uuid que casa com '\n '\\`companies.uuid\\`; o de-para pede o id NUMÉRICO de companies, que é o que o '\n 'ledger resolve, e a ponte é feita no extrator. 223 salas no tenant 1, 219 '\n 'ativas, em 127 unidades. Ver a migration 091: apontar esta tabela para '\n 'app.units transformaria cada sala numa filial.'\n);\n\nSELECT migration._allow(\n 'schedule_grid_blocks', 'public.schedules', 'agenda', 'agenda_schedule',\n jsonb_build_object('kind', 'working_hours'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'weekday', 'day_of_week',\n 'start_time', 'starts_at',\n 'end_time', 'ends_at',\n 'week_interval', 'week_interval',\n 'professional_id', 'assignee_id@professionals',\n 'service_location_id', 'resource_id@service_locations',\n 'unit_company_id', 'unit_id@companies',\n 'grid_valid_from', 'valid_from',\n 'grid_valid_to', 'valid_to',\n 'grid_kind', 'grid_kind',\n 'grid_slot_interval', 'slot_interval_minutes',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'grid_id', '-',\n 'grid_name', 'jsonb:agenda.grid_name',\n 'allowed_service_ids', 'jsonb:agenda.v1_allowed_service_ids'),\n 'A grade de atendimento. A GRADE (schedule_grids) não tem linha no destino: '\n 'vigência, tipo e intervalo de slot descem para cada bloco, porque \\`schedules\\` '\n 'descreve UMA janela. 1.331 blocos no tenant 1 contra 44.493 se fossem '\n 'expandidos por data — e expandir perderia a grade eterna e mataria o espelho '\n 'D-1. O cursor do extrator é greatest(bloco, grade): mexer só na grade não '\n 'move o updated_at do bloco. \\`allowed_service_ids\\` vai para metadata porque '\n 'está NULL em 1.331 de 1.331 — o recurso existe no V1 e ninguém usa.'\n);\n\nSELECT migration._allow(\n 'schedule_blocks', 'public.schedules', 'agenda', 'agenda_schedule',\n jsonb_build_object('kind', 'block'),\n NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'professional_id', 'assignee_id@professionals',\n 'start_time', 'starts_at',\n 'end_time', 'ends_at',\n 'date_from', 'valid_from',\n 'date_to', 'valid_to',\n 'v1_specific_date', 'specific_date',\n 'all_day', 'all_day',\n 'week_interval', 'week_interval',\n 'weekdays', 'v1_weekdays',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'tenant_id', '-',\n 'created_by', '-',\n 'title', 'jsonb:agenda.title',\n 'description', 'jsonb:agenda.description'),\n 'O bloqueio: almoço, feriado, \"a dra. vai atender\". Vai para \\`schedules\\` '\n 'kind=block e não para \\`appointments\\` kind=block porque o bloqueio do V1 pode '\n 'ser RECORRENTE (weekdays + week_interval + faixa de datas) e um appointment '\n 'é evento único. Sem unidade de propósito: o bloqueio é da PESSOA, e unit_id '\n 'nulo significa \"vale onde ela atende\". 8 linhas no tenant 1, todas de um dia.'\n);\n\n-- A grade sai da excluded_tables com o motivo REESCRITO. Não é \"sem destino\":\n-- ela atravessa inteira, dentro do bloco. Sem isto o dashboard de cobertura\n-- marcaria 482 linhas em vermelho que na verdade estão no destino.\nDELETE FROM migration.excluded_tables\n WHERE source_table IN ('schedule_grid_blocks', 'service_locations');\n\nINSERT INTO migration.excluded_tables (source_table, reason)\nVALUES ('schedule_grids',\n 'Não tem linha própria no destino, e isso é a decisão — não a ausência '\n 'dela. Vigência, tipo e intervalo de slot descem para cada '\n 'schedule_grid_blocks, que vira public.schedules. Ver plugin-agenda/010.')\nON CONFLICT (source_table) WHERE tenant_id IS NULL\nDO UPDATE SET reason = EXCLUDED.reason;\n\nRESET migration.allowlist_unlock;\n\nREVOKE ALL ON FUNCTION migration.upsert_agenda_resource(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_agenda_resource(uuid, uuid, jsonb, jsonb) TO service_role;\nREVOKE ALL ON FUNCTION migration.upsert_agenda_schedule(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_agenda_schedule(uuid, uuid, jsonb, jsonb) TO service_role;\n`\n\nexport const MIGRATION_011_A_SALA_ENTRA_NO_COMPROMISSO = `-- A sala entra no compromisso.\n--\n-- 010 criou \\`plg_agenda_resources\\` e \\`appointments.resource_id\\`, e trouxe as\n-- 159 salas. Os 353.895 compromissos já migrados continuam apontando para a\n-- sala por um NÚMERO cru em \\`metadata.agenda.v1_service_location_ref\\` — que era\n-- tudo o que existia quando 008 rodou.\n--\n-- Duas coisas aqui, e a ordem importa:\n--\n-- 1. O de-para passa a mapear \\`service_location_id\\` para \\`resource_id\\`, para\n-- a próxima onda preencher a coluna sozinha.\n-- 2. O que JÁ atravessou é costurado pelo ledger, sem reler a origem. São\n-- 352.002 linhas com a referência guardada; reoferecê-las ao motor custaria\n-- uma varredura de 354 mil linhas no V1 de produção para escrever uma\n-- coluna que o próprio destino já sabe deduzir.\n--\n-- A costura é pelo LEDGER e não por adivinhação: \\`migration.ledger\\` sabe que o\n-- \\`service_locations\\` número N daquele projeto virou o uuid X naquele tenant. É\n-- a mesma tabela que o writer consultaria, só que consultada uma vez para todos.\n--\n-- Idempotente: o UPDATE só toca linha com \\`resource_id\\` nulo.\n\nSET LOCAL migration.allowlist_unlock = 'on';\n\n-- \\`service_location_id\\` deixa de ir só para metadata e passa a resolver pelo\n-- ledger. A entrada de metadata continua existindo nas linhas antigas — e isso\n-- não é sujeira: é o número da origem, que é o que permite auditar esta costura\n-- depois de ela ter acontecido.\nUPDATE migration.allowlist\n SET columns = columns || jsonb_build_object('service_location_id', 'resource_id@service_locations')\n WHERE source_table = 'appointments';\n\nRESET migration.allowlist_unlock;\n\n-- ─────────────────────────────────────────────────────────────────────────────\n-- A costura\n-- ─────────────────────────────────────────────────────────────────────────────\n--\n-- Em lotes, e não num UPDATE só: 352 mil linhas numa transação seguram a tabela\n-- que a agenda lê o dia inteiro. O laço para quando uma passada não move nada,\n-- que é também o que torna o arquivo replay-safe — na segunda vez a primeira\n-- passada já não acha linha.\nDO $$\nDECLARE\n v_moved integer;\n v_total bigint := 0;\nBEGIN\n IF to_regclass('migration.ledger') IS NULL THEN RETURN; END IF;\n\n LOOP\n WITH alvo AS (\n SELECT a.id, l.target_id AS resource_id\n FROM public.appointments a\n JOIN migration.ledger l\n ON l.tenant_id = a.tenant_id\n AND l.source_table = 'service_locations'\n AND l.status = 'migrated'\n AND l.source_numeric_id = (a.metadata -> 'agenda' ->> 'v1_service_location_ref')::bigint\n WHERE a.resource_id IS NULL\n AND a.metadata -> 'agenda' ? 'v1_service_location_ref'\n LIMIT 5000\n )\n UPDATE public.appointments a\n SET resource_id = alvo.resource_id\n FROM alvo\n WHERE a.id = alvo.id;\n\n GET DIAGNOSTICS v_moved = ROW_COUNT;\n v_total := v_total + v_moved;\n EXIT WHEN v_moved = 0;\n END LOOP;\n\n RAISE NOTICE 'appointments.resource_id costurado em % linha(s)', v_total;\nEND $$;\n\n-- A agenda por sala: \"o que acontece na Sala 2 amanhã\".\nCREATE INDEX IF NOT EXISTS idx_appointments_tenant_resource_starts\n ON public.appointments USING btree (tenant_id, resource_id, starts_at)\n WHERE resource_id IS NOT NULL;\n`\n\nexport const MIGRATION_012_A_SALA_SEM_NOME_TAMBEM_E_UMA_SALA = `-- A sala sem nome também é uma sala.\n--\n-- 010 recusou a sala cujo \\`name\\` vem vazio: sem nome, o writer mandava para a\n-- quarentena. Uma linha de 223 — e 17 compromissos apontam para ela.\n--\n-- Recusar estava errado pelo mesmo motivo que \\`upsert_appointment_item\\` já\n-- tinha resolvido para o serviço: o nome é uma ETIQUETA, e a falta dela não\n-- torna a linha ambígua. O que se perde recusando é o VÍNCULO — 17 atendimentos\n-- ficam sem saber em que sala aconteceram, para poupar um rótulo feio.\n--\n-- Então cai para o número da origem, que é sempre verdadeiro e diz na cara que\n-- é um remendo: \"Sala 576\". Quem for arrumar o cadastro no V1 vê a linha e sabe\n-- o que procurar.\n--\n-- Idempotente.\n\nCREATE OR REPLACE FUNCTION migration.upsert_agenda_resource(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_unit uuid;\n v_name text;\n v_ref text;\n v_cap integer;\n v_existing uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a resource needs an id');\n END IF;\n\n BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;\n -- Sala sem unidade continua recusada, e a diferença com o nome é essa: uma\n -- sala sem unidade apareceria na agenda de todas as 63 filiais. Não é rótulo,\n -- é escopo.\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'a resource needs the unit it lives in');\n END IF;\n\n v_ref := nullif(v_meta -> 'agenda' ->> 'v1_service_location_ref', '');\n v_name := coalesce(\n nullif(trim(coalesce(p_row ->> 'name', '')), ''),\n CASE WHEN v_ref IS NOT NULL THEN 'Sala ' || v_ref END,\n 'Sala');\n\n v_cap := greatest(coalesce(nullif(p_row ->> 'capacity', '')::integer, 1), 1);\n\n SELECT tenant_id INTO v_existing FROM public.plg_agenda_resources WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n 'reason', format('resource %s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing IS NULL THEN\n INSERT INTO public.plg_agenda_resources\n (id, tenant_id, unit_id, name, kind, capacity, is_active, sort_order, metadata, created_at, updated_at)\n VALUES\n (p_id, p_tenant, v_unit, v_name, coalesce(p_args ->> 'kind', 'room'), v_cap,\n coalesce((p_row ->> 'is_active')::boolean, true),\n coalesce(nullif(p_row ->> 'sort_order', '')::integer, 0), v_meta,\n coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),\n coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));\n v_created := true;\n ELSE\n UPDATE public.plg_agenda_resources SET\n unit_id = v_unit,\n name = v_name,\n capacity = v_cap,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n metadata = migration._merge_meta(metadata, v_meta),\n updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN\n PERFORM migration._audit(p_tenant, 'public.plg_agenda_resources', p_id,\n jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\nREVOKE ALL ON FUNCTION migration.upsert_agenda_resource(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_agenda_resource(uuid, uuid, jsonb, jsonb) TO service_role;\n`\n\nexport const MIGRATION_013_O_SELO_DO_ATENDIMENTO_TEM_ONDE_MORAR = `-- O selo do atendimento tem onde morar.\n--\n-- A grade, a sala e o bloqueio atravessaram nas 010..012: 1.331 janelas, 160\n-- salas, 352.002 agendamentos costurados à sala. O que ficou de fora são os\n-- VÍNCULO que a tela do atendimento lê para pintar selo — pouca linha, feature\n-- inteira.\n--\n-- appointment_prepaid_items 53 o selo \"Compra antecipada\"\n--\n-- ── Por que 53 linhas merecem uma tabela ────────────────────────────────────\n--\n-- O V2 já tem o DESFECHO do item pré-pago: \\`plg_clinical_prepaid_outcomes\\`, 30\n-- linhas migradas. Não tem a PERGUNTA — o que este agendamento veio consumir.\n-- É \\`PrepaidOutcomeGate.tsx\\` no V1, e o comentário do arquivo diz a fonte com\n-- todas as letras: \"Fonte: appointment_prepaid_items (o que o agendamento veio\n-- validar)\". Sem ela o portão do V2 não tem o que perguntar, e o desfecho fica\n-- sendo a resposta de uma pergunta que sumiu.\n--\n-- ── Por que a FK para public.items é legal aqui ─────────────────────────────\n--\n-- \\`appointment_prepaid_items.invoice_item_id\\` aponta para a parcela do pacote.\n-- No V2 isso é \\`public.items\\`, tabela da BASE — e financial roda ANTES de\n-- agenda na chain. Apontar para \\`plg_financial_*\\` seria o contrário e quebraria\n-- a ordem; apontar para \\`items\\` não.\n--\n-- ── O que NÃO está aqui ─────────────────────────────────────────────────────\n--\n-- \\`appointment_interests\\` (762 linhas) é o selo \"Interesses\", e ele NÃO mora\n-- aqui: aponta para \\`opportunity_interests\\`, que é a cesta do funil. Uma tabela\n-- de agenda não pode ter FK numa de crm, porque crm roda DEPOIS. As duas vivem\n-- em plugin-crm, na migration da cesta.\n--\n-- \\`online_booking_configs\\` também não: é CONFIGURAÇÃO de uma linha só, e\n-- configuração no V2 não é tabela — mora em \\`tenants.settings.plugins.agenda\\`.\n-- Ver a 014, que desfaz a tabela que esta migration chegou a criar.\n--\n-- \\`schedules.slot_interval_minutes\\` já está certo: 448 grades em 30min, 33 em\n-- 15min, 1 em 5min no V1; 1.280 / 50 / 1 no V2. A queixa \"intervalo por grade\"\n-- é tela que não lê a coluna, não porte. Nada a fazer aqui.\n--\n-- Idempotente.\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 1. O que este agendamento veio consumir\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_agenda_appointment_prepaid_items (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n -- A cerca da franquia. Herdada do agendamento: não há item pré-pago sem\n -- agendamento, e o agendamento sempre tem unidade.\n unit_id uuid NOT NULL,\n appointment_id uuid NOT NULL,\n -- A parcela do pacote que este atendimento consome.\n item_id uuid NOT NULL,\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_agenda_appointment_prepaid_items_pkey PRIMARY KEY (id)\n);\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_prepaid_tenant_fk') THEN\n ALTER TABLE public.plg_agenda_appointment_prepaid_items\n ADD CONSTRAINT plg_agenda_prepaid_tenant_fk\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_prepaid_appointment_fk') THEN\n ALTER TABLE public.plg_agenda_appointment_prepaid_items\n ADD CONSTRAINT plg_agenda_prepaid_appointment_fk\n FOREIGN KEY (appointment_id) REFERENCES public.appointments(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_prepaid_item_fk') THEN\n ALTER TABLE public.plg_agenda_appointment_prepaid_items\n ADD CONSTRAINT plg_agenda_prepaid_item_fk\n FOREIGN KEY (item_id) REFERENCES public.items(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_prepaid_unit_fk') THEN\n ALTER TABLE public.plg_agenda_appointment_prepaid_items\n ADD CONSTRAINT plg_agenda_prepaid_unit_fk\n FOREIGN KEY (unit_id) REFERENCES app.units(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\n-- O mesmo item não se consome duas vezes no mesmo agendamento.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_agenda_prepaid_uq\n ON public.plg_agenda_appointment_prepaid_items (appointment_id, item_id);\n-- A pergunta da tela: o que ESTE agendamento veio validar.\nCREATE INDEX IF NOT EXISTS plg_agenda_prepaid_appointment_idx\n ON public.plg_agenda_appointment_prepaid_items (tenant_id, appointment_id);\n-- A pergunta do portão: este item já foi consumido em algum lugar?\nCREATE INDEX IF NOT EXISTS plg_agenda_prepaid_item_idx\n ON public.plg_agenda_appointment_prepaid_items (tenant_id, item_id);\n\nCOMMENT ON TABLE public.plg_agenda_appointment_prepaid_items IS\n 'O que o agendamento veio consumir de um pacote já pago — o selo \"Compra antecipada\". ← appointment_prepaid_items';\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 3. updated_at\n-- ═══════════════════════════════════════════════════════════════════════════\n\nDO $$\nDECLARE t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY['plg_agenda_appointment_prepaid_items'] LOOP\n EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', t || '_updated_at', t);\n EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', t || '_updated_at', t);\n END LOOP;\nEND $$;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 4. Quem enxerga\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- O item pré-pago é de UNIDADE, como a sala da 010: quem vê a agenda da filial\n-- vê o que os agendamentos dela vieram consumir.\n--\n-- A configuração do agendamento online é de TENANT e exige \\`agenda.manage\\`:\n-- é a página que o público vê, e mudá-la não é tarefa de recepção.\n\nALTER TABLE public.plg_agenda_appointment_prepaid_items ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_appointment_prepaid_items FORCE ROW LEVEL SECURITY;\nGRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.plg_agenda_appointment_prepaid_items TO authenticated;\nGRANT ALL ON TABLE public.plg_agenda_appointment_prepaid_items TO service_role;\n\nDROP POLICY IF EXISTS plg_agenda_prepaid_authz_select ON public.plg_agenda_appointment_prepaid_items;\nCREATE POLICY plg_agenda_prepaid_authz_select ON public.plg_agenda_appointment_prepaid_items\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_appointment_prepaid_items.unit_id))\n AND (SELECT app.has_permission('agenda.read', plg_agenda_appointment_prepaid_items.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_prepaid_authz_insert ON public.plg_agenda_appointment_prepaid_items;\nCREATE POLICY plg_agenda_prepaid_authz_insert ON public.plg_agenda_appointment_prepaid_items\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_appointment_prepaid_items.unit_id))\n AND (SELECT app.has_permission('agenda.create', plg_agenda_appointment_prepaid_items.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_prepaid_authz_update ON public.plg_agenda_appointment_prepaid_items;\nCREATE POLICY plg_agenda_prepaid_authz_update ON public.plg_agenda_appointment_prepaid_items\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_appointment_prepaid_items.unit_id))\n AND (SELECT app.has_permission('agenda.edit', plg_agenda_appointment_prepaid_items.unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_appointment_prepaid_items.unit_id))\n AND (SELECT app.has_permission('agenda.edit', plg_agenda_appointment_prepaid_items.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_prepaid_authz_delete ON public.plg_agenda_appointment_prepaid_items;\nCREATE POLICY plg_agenda_prepaid_authz_delete ON public.plg_agenda_appointment_prepaid_items\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(plg_agenda_appointment_prepaid_items.unit_id))\n AND (SELECT app.has_permission('agenda.delete', plg_agenda_appointment_prepaid_items.unit_id)));\n\n-- NÃO entra em \\`scoped_resources\\`, e isso é decisão. Aquele registro existe para\n-- o RECURSO que a tela de visibilidade oferece — e exige \\`owner_id\\`, porque a\n-- pergunta que ele responde é \"quem é o dono desta linha\". \\`plg_agenda_appointment_prepaid_items\\`\n-- é uma PONTE: ela não tem dono, herda a unidade de quem ela liga, e a cerca\n-- dela já está nas políticas acima. Registrá-la ali pediria uma coluna\n-- \\`owner_id\\` inventada só para satisfazer um NOT NULL.\n`\n\nexport const MIGRATION_014_A_CONFIGURACAO_NAO_E_UMA_TABELA = `-- A configuração não é uma tabela.\n--\n-- A 013 criou \\`public.plg_agenda_booking_configs\\` para receber\n-- \\`online_booking_configs\\` do V1. Era uma tabela de UMA linha, e foi erro meu.\n-- Esta migration a desfaz.\n--\n-- ── Como o SDK guarda configuração de plugin ────────────────────────────────\n--\n-- \\`packages/admin/src/hooks/useTenantPluginSettings.ts\\`, e está escrito no\n-- cabeçalho do próprio arquivo:\n--\n-- tenants.settings = { ..., plugins: { [pluginId]: { key: value, ... } } }\n--\n-- A leitura sai de \\`currentOrg.settings.plugins[pluginId]\\` e a escrita de\n-- \\`adapter.updateOrg(orgId, { settings })\\`. É por aí que passam as telas de\n-- /settings de agenda, crm, inventory, financial, tasks e courses.\n--\n-- E a agenda JÁ usa: \\`AgendaGeneralSettings.tsx\\` importa\n-- \\`useTenantPluginSettings\\` e declara \\`AGENDA_TOGGLE_DEFAULTS\\`. Criar\n-- \\`plg_agenda_booking_configs\\` abriria um SEGUNDO lugar para a configuração da\n-- agenda — a tela lendo de um, o porte escrevendo no outro.\n--\n-- ── Mas existem plg_*_settings no cluster ───────────────────────────────────\n--\n-- Existem, e não contradizem: \\`plg_shop_settings\\`, \\`plg_tables_settings\\`,\n-- \\`plg_reservations_settings\\`, \\`plg_crm_engagement_settings\\`,\n-- \\`plg_inventory_product_settings\\`. Todas têm \\`unit_id\\` e existem porque a\n-- configuração delas varia POR UNIDADE e é lida por RPC no caminho quente\n-- (fechar comanda, calcular frete) — não por uma tela de toggles.\n--\n-- \\`online_booking_configs\\` não é nenhum dos dois casos: UMA linha no V1\n-- inteiro, tenant 1, e ninguém a lê num caminho quente. É toggle.\n--\n-- A troco de registro, se um dia ela precisar variar por unidade, o lugar certo\n-- é \\`plg_agenda_settings\\` com \\`unit_id\\`, no formato das outras cinco — e não\n-- \\`_configs\\`, que não é nome que este SDK usa.\n--\n-- ── O que se perde, e é de propósito ────────────────────────────────────────\n--\n-- \\`allowed_units\\`, \\`allowed_services\\` e \\`allowed_professionals\\` viram arrays de\n-- uuid DENTRO do jsonb, sem chave estrangeira. Apagar uma unidade deixa o id\n-- órfão na lista. É a mesma troca que \\`tenants.settings.branding\\` já faz, e\n-- para uma linha ela paga: integridade referencial num array de configuração\n-- custaria uma tabela de ligação para resolver um problema que o V1 não tem em\n-- nenhum tenant.\n--\n-- Idempotente. A tabela nunca recebeu linha: o porte dela ainda não rodou.\n\nDO $$\nBEGIN\n IF to_regclass('public.plg_agenda_booking_configs') IS NOT NULL THEN\n IF EXISTS (SELECT 1 FROM public.plg_agenda_booking_configs) THEN\n RAISE EXCEPTION\n 'plg_agenda_booking_configs tem linha — mover para tenants.settings.plugins.agenda antes de derrubar';\n END IF;\n DROP TABLE public.plg_agenda_booking_configs;\n END IF;\nEND $$;\n`\n\nexport const MIGRATION_015_O_FERIADO_TAMBEM_TEM_OBSERVACAO_E_ETIQUETA = `-- ---------------------------------------------------------------------------\n-- O feriado também tem observação e etiqueta.\n--\n-- Irmã da 150 do núcleo e da 103 do financeiro, para o último cadastro que\n-- ficou de fora. Mesmo argumento, e aqui a etiqueta tem um uso imediato:\n-- «bancário», «municipal», «ponto facultativo» são recortes que mudam quem\n-- fecha e quem não fecha, e que nenhum campo do cadastro responde.\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.holidays\n ADD COLUMN IF NOT EXISTS notes text,\n ADD COLUMN IF NOT EXISTS tags text[] DEFAULT '{}'::text[];\n`\n\nexport const MIGRATION_016_A_VEZ_E_UM_FATO = `-- A lista da vez is an opt-in agenda addon. Its state is per unit, and every\n-- transition produces an immutable event so reception never becomes the\n-- unaudited explanation for why somebody was skipped.\n\nCREATE TABLE IF NOT EXISTS public.plg_agenda_turn_queues (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid NOT NULL REFERENCES app.units(id) ON DELETE CASCADE,\n queue_key text NOT NULL DEFAULT 'walk_in',\n name text NOT NULL DEFAULT 'Próximo da vez',\n is_active boolean NOT NULL DEFAULT true,\n geofence_required boolean NOT NULL DEFAULT true,\n geofence_radius_meters integer NOT NULL DEFAULT 80 CHECK (geofence_radius_meters BETWEEN 10 AND 1000),\n upcoming_booking_block_minutes integer NOT NULL DEFAULT 30 CHECK (upcoming_booking_block_minutes BETWEEN 0 AND 240),\n round_direction smallint NOT NULL DEFAULT 1 CHECK (round_direction IN (-1, 1)),\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (tenant_id, unit_id, queue_key)\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_agenda_turn_entries (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n queue_id uuid NOT NULL REFERENCES public.plg_agenda_turn_queues(id) ON DELETE CASCADE,\n unit_id uuid NOT NULL REFERENCES app.units(id) ON DELETE CASCADE,\n professional_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n status text NOT NULL DEFAULT 'unavailable' CHECK (status IN ('available', 'serving', 'unavailable')),\n position bigint NOT NULL DEFAULT 0,\n previous_position bigint,\n available_at timestamptz,\n state_changed_at timestamptz NOT NULL DEFAULT now(),\n appointment_id uuid REFERENCES public.appointments(id) ON DELETE SET NULL,\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (queue_id, professional_id)\n);\n\nCREATE INDEX IF NOT EXISTS plg_agenda_turn_entries_order_idx\n ON public.plg_agenda_turn_entries (queue_id, status, position);\n\nCREATE TABLE IF NOT EXISTS public.plg_agenda_turn_events (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n queue_id uuid NOT NULL REFERENCES public.plg_agenda_turn_queues(id) ON DELETE CASCADE,\n unit_id uuid NOT NULL REFERENCES app.units(id) ON DELETE CASCADE,\n professional_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n event_type text NOT NULL,\n origin text NOT NULL CHECK (origin IN ('app', 'frontdesk', 'automatic')),\n actor_user_id uuid,\n appointment_id uuid REFERENCES public.appointments(id) ON DELETE SET NULL,\n reason text,\n inside_geofence boolean,\n location_accuracy_meters numeric,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n occurred_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS plg_agenda_turn_events_timeline_idx\n ON public.plg_agenda_turn_events (queue_id, occurred_at DESC);\n\nALTER TABLE public.plg_agenda_turn_queues ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_queues FORCE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_entries ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_entries FORCE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_events ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_events FORCE ROW LEVEL SECURITY;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_queues' AND policyname = 'plg_agenda_turn_queues_read') THEN\n CREATE POLICY plg_agenda_turn_queues_read ON public.plg_agenda_turn_queues\n FOR SELECT TO authenticated\n USING (tenant_id = app.current_tenant_id() AND app.has_unit(unit_id)\n AND app.has_permission('agenda.turn_queue.read', unit_id));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_entries' AND policyname = 'plg_agenda_turn_entries_read') THEN\n CREATE POLICY plg_agenda_turn_entries_read ON public.plg_agenda_turn_entries\n FOR SELECT TO authenticated\n USING (tenant_id = app.current_tenant_id() AND app.has_unit(unit_id)\n AND app.has_permission('agenda.turn_queue.read', unit_id));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_events' AND policyname = 'plg_agenda_turn_events_read') THEN\n CREATE POLICY plg_agenda_turn_events_read ON public.plg_agenda_turn_events\n FOR SELECT TO authenticated\n USING (tenant_id = app.current_tenant_id() AND app.has_unit(unit_id)\n AND app.has_permission('agenda.turn_queue.read', unit_id));\n END IF;\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_set_availability(\n p_available boolean,\n p_unit_id uuid,\n p_latitude numeric DEFAULT NULL,\n p_longitude numeric DEFAULT NULL,\n p_accuracy_meters numeric DEFAULT NULL,\n p_origin text DEFAULT 'app'\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_actor uuid := auth.uid();\n v_person uuid;\n v_queue public.plg_agenda_turn_queues%ROWTYPE;\n v_lat numeric;\n v_lng numeric;\n v_distance numeric;\n v_position bigint;\n v_entry uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501';\n END IF;\n IF p_unit_id IS NULL THEN\n RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023';\n END IF;\n IF p_origin <> 'app' THEN\n RAISE EXCEPTION 'self_availability_origin_must_be_app' USING ERRCODE = '22023';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.edit', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.edit is required' USING ERRCODE = '42501';\n END IF;\n\n v_person := app.person_for_login(v_tenant, v_actor);\n IF v_person IS NULL THEN\n RAISE EXCEPTION 'sem_ficha_de_profissional' USING ERRCODE = '42501';\n END IF;\n\n INSERT INTO public.plg_agenda_turn_queues (tenant_id, unit_id)\n VALUES (v_tenant, p_unit_id)\n ON CONFLICT (tenant_id, unit_id, queue_key) DO NOTHING;\n\n SELECT * INTO v_queue\n FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id\n AND q.queue_key = 'walk_in' AND q.is_active\n FOR UPDATE;\n IF v_queue.id IS NULL THEN\n RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '42501';\n END IF;\n\n IF p_available AND v_queue.geofence_required THEN\n SELECT u.latitude, u.longitude INTO v_lat, v_lng\n FROM public.v_units u\n WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;\n IF v_lat IS NULL OR v_lng IS NULL THEN\n RAISE EXCEPTION 'unit_has_no_coordinates' USING ERRCODE = '22023';\n END IF;\n IF p_latitude IS NULL OR p_longitude IS NULL THEN\n RAISE EXCEPTION 'location_required' USING ERRCODE = '22023';\n END IF;\n v_distance := 6371000 * acos(least(1, greatest(-1,\n cos(radians(v_lat::double precision)) * cos(radians(p_latitude::double precision))\n * cos(radians(p_longitude::double precision) - radians(v_lng::double precision))\n + sin(radians(v_lat::double precision)) * sin(radians(p_latitude::double precision))\n )));\n IF v_distance > v_queue.geofence_radius_meters THEN\n RAISE EXCEPTION 'outside_geofence' USING ERRCODE = '42501';\n END IF;\n END IF;\n\n SELECT coalesce(max(e.position), 0) + 1 INTO v_position\n FROM public.plg_agenda_turn_entries e WHERE e.queue_id = v_queue.id;\n\n INSERT INTO public.plg_agenda_turn_entries\n (tenant_id, queue_id, unit_id, professional_id, status, position, available_at, state_changed_at, appointment_id, updated_at)\n VALUES\n (v_tenant, v_queue.id, p_unit_id, v_person,\n CASE WHEN p_available THEN 'available' ELSE 'unavailable' END,\n CASE WHEN p_available THEN v_position ELSE 0 END,\n CASE WHEN p_available THEN now() ELSE NULL END,\n now(), NULL, now())\n ON CONFLICT (queue_id, professional_id) DO UPDATE SET\n status = EXCLUDED.status,\n position = CASE WHEN p_available THEN v_position ELSE public.plg_agenda_turn_entries.position END,\n available_at = EXCLUDED.available_at,\n state_changed_at = now(),\n appointment_id = NULL,\n updated_at = now()\n RETURNING id INTO v_entry;\n\n INSERT INTO public.plg_agenda_turn_events\n (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id,\n inside_geofence, location_accuracy_meters, payload)\n VALUES\n (v_tenant, v_queue.id, p_unit_id, v_person,\n CASE WHEN p_available THEN 'available' ELSE 'left' END,\n 'app', v_actor, CASE WHEN p_available THEN true ELSE NULL END,\n CASE WHEN p_available THEN p_accuracy_meters ELSE NULL END,\n jsonb_build_object('entry_id', v_entry));\n\n RETURN jsonb_build_object('ok', true, 'status', CASE WHEN p_available THEN 'available' ELSE 'unavailable' END);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_snapshot(p_unit_id uuid)\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' STABLE AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues%ROWTYPE;\n v_person uuid;\n v_entries jsonb;\n v_events jsonb;\nBEGIN\n IF p_unit_id IS NULL THEN\n RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';\n END IF;\n v_person := app.person_for_login(v_tenant, auth.uid());\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' AND q.is_active;\n IF v_queue.id IS NULL THEN\n RETURN jsonb_build_object('queue_id', NULL, 'unit_id', p_unit_id, 'queue_name', 'Próximo da vez',\n 'current_professional_id', v_person, 'entries', '[]'::jsonb, 'events', '[]'::jsonb);\n END IF;\n\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'id', e.id, 'queue_id', e.queue_id, 'unit_id', e.unit_id,\n 'professional_id', e.professional_id, 'professional_name', p.name,\n 'professional_avatar_url', p.avatar_url, 'status', e.status, 'position', e.position,\n 'state_changed_at', e.state_changed_at, 'available_at', e.available_at,\n 'appointment_id', e.appointment_id\n ) ORDER BY CASE e.status WHEN 'available' THEN 0 WHEN 'serving' THEN 1 ELSE 2 END, e.position), '[]'::jsonb)\n INTO v_entries\n FROM public.plg_agenda_turn_entries e\n JOIN public.people p ON p.id = e.professional_id AND p.tenant_id = e.tenant_id\n WHERE e.queue_id = v_queue.id;\n\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'id', ev.id, 'professional_id', ev.professional_id, 'professional_name', p.name,\n 'event_type', ev.event_type, 'origin', ev.origin, 'reason', ev.reason,\n 'occurred_at', ev.occurred_at\n ) ORDER BY ev.occurred_at DESC), '[]'::jsonb)\n INTO v_events\n FROM (\n SELECT * FROM public.plg_agenda_turn_events\n WHERE queue_id = v_queue.id AND occurred_at >= date_trunc('day', now())\n ORDER BY occurred_at DESC LIMIT 50\n ) ev\n JOIN public.people p ON p.id = ev.professional_id AND p.tenant_id = ev.tenant_id;\n\n RETURN jsonb_build_object('queue_id', v_queue.id, 'unit_id', p_unit_id, 'queue_name', v_queue.name,\n 'current_professional_id', v_person, 'entries', v_entries, 'events', v_events);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_override(\n p_professional_id uuid,\n p_unit_id uuid,\n p_available boolean,\n p_reason text\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues%ROWTYPE;\n v_position bigint;\nBEGIN\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.manage', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.manage is required' USING ERRCODE = '42501';\n END IF;\n IF nullif(trim(p_reason), '') IS NULL THEN\n RAISE EXCEPTION 'override_reason_required' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' AND q.is_active\n FOR UPDATE;\n IF v_queue.id IS NULL THEN RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '42501'; END IF;\n SELECT coalesce(max(position), 0) + 1 INTO v_position FROM public.plg_agenda_turn_entries WHERE queue_id = v_queue.id;\n INSERT INTO public.plg_agenda_turn_entries\n (tenant_id, queue_id, unit_id, professional_id, status, position, available_at, state_changed_at, updated_at)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id,\n CASE WHEN p_available THEN 'available' ELSE 'unavailable' END,\n CASE WHEN p_available THEN v_position ELSE 0 END,\n CASE WHEN p_available THEN now() ELSE NULL END, now(), now())\n ON CONFLICT (queue_id, professional_id) DO UPDATE SET\n status = EXCLUDED.status, position = CASE WHEN p_available THEN v_position ELSE public.plg_agenda_turn_entries.position END,\n available_at = EXCLUDED.available_at, state_changed_at = now(), appointment_id = NULL, updated_at = now();\n INSERT INTO public.plg_agenda_turn_events\n (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, reason)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id,\n CASE WHEN p_available THEN 'override_available' ELSE 'override_left' END,\n 'frontdesk', auth.uid(), trim(p_reason));\n RETURN jsonb_build_object('ok', true);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_on_appointment_status()\nRETURNS trigger\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_entry public.plg_agenda_turn_entries%ROWTYPE;\n v_position bigint;\n v_event text;\nBEGIN\n IF NEW.assignee_id IS NULL OR NEW.unit_id IS NULL OR NEW.status IS NOT DISTINCT FROM OLD.status THEN RETURN NEW; END IF;\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.tenant_id = NEW.tenant_id AND e.unit_id = NEW.unit_id AND e.professional_id = NEW.assignee_id\n FOR UPDATE;\n IF v_entry.id IS NULL THEN RETURN NEW; END IF;\n\n IF NEW.status = 'in_progress' AND OLD.status <> 'in_progress' AND v_entry.status = 'available' THEN\n SELECT coalesce(max(position), 0) + 1 INTO v_position FROM public.plg_agenda_turn_entries WHERE queue_id = v_entry.queue_id;\n UPDATE public.plg_agenda_turn_entries SET status = 'serving', previous_position = position,\n position = v_position, appointment_id = NEW.id, state_changed_at = now(), updated_at = now()\n WHERE id = v_entry.id;\n v_event := 'advanced_to_service';\n ELSIF OLD.status = 'in_progress' AND NEW.status = 'cancelled' AND v_entry.appointment_id = NEW.id THEN\n UPDATE public.plg_agenda_turn_entries SET status = 'available',\n position = coalesce(previous_position, position), appointment_id = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n v_event := 'service_cancelled_restored';\n ELSIF OLD.status = 'in_progress' AND NEW.status = 'completed' AND v_entry.appointment_id = NEW.id THEN\n UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', appointment_id = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n v_event := 'service_completed';\n ELSE\n RETURN NEW;\n END IF;\n\n INSERT INTO public.plg_agenda_turn_events\n (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id)\n VALUES (NEW.tenant_id, v_entry.queue_id, NEW.unit_id, NEW.assignee_id, v_event, 'automatic', NEW.id);\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS appointments_turn_queue_status ON public.appointments;\nCREATE TRIGGER appointments_turn_queue_status\n AFTER UPDATE OF status ON public.appointments\n FOR EACH ROW EXECUTE FUNCTION public.agenda_turn_queue_on_appointment_status();\n\nREVOKE ALL ON public.plg_agenda_turn_queues, public.plg_agenda_turn_entries, public.plg_agenda_turn_events FROM PUBLIC, anon, authenticated;\nGRANT SELECT ON public.plg_agenda_turn_queues, public.plg_agenda_turn_entries, public.plg_agenda_turn_events TO authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_set_availability(boolean, uuid, numeric, numeric, numeric, text) FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_snapshot(uuid) FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_override(uuid, uuid, boolean, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_set_availability(boolean, uuid, numeric, numeric, numeric, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_snapshot(uuid) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_override(uuid, uuid, boolean, text) TO authenticated;\n\nCOMMENT ON TABLE public.plg_agenda_turn_events IS\n 'Immutable audit trail for the walk-in turn queue. Location stores only the inside/outside verdict and accuracy, never coordinates.';\n`\n\nexport const MIGRATION_017_A_EQUIPE_DO_DIA_APARECE = `-- The board must show the whole team expected at this unit today, including\n-- professionals who have not explicitly joined the walk-in queue yet.\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_snapshot(p_unit_id uuid)\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' STABLE AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues%ROWTYPE;\n v_person uuid;\n v_timezone text;\n v_day date;\n v_entries jsonb;\n v_events jsonb;\n v_professionals jsonb;\nBEGIN\n IF p_unit_id IS NULL THEN\n RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';\n END IF;\n\n SELECT coalesce(t.settings->>'timezone', 'America/Sao_Paulo')\n INTO v_timezone\n FROM public.tenants t\n WHERE t.id = v_tenant;\n v_day := (now() AT TIME ZONE v_timezone)::date;\n v_person := app.person_for_login(v_tenant, auth.uid());\n\n SELECT * INTO v_queue\n FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant\n AND q.unit_id = p_unit_id\n AND q.queue_key = 'walk_in'\n AND q.is_active;\n\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'id', e.id, 'queue_id', e.queue_id, 'unit_id', e.unit_id,\n 'professional_id', e.professional_id, 'professional_name', p.name,\n 'professional_avatar_url', p.avatar_url, 'status', e.status, 'position', e.position,\n 'state_changed_at', e.state_changed_at, 'available_at', e.available_at,\n 'appointment_id', e.appointment_id\n ) ORDER BY CASE e.status WHEN 'available' THEN 0 WHEN 'serving' THEN 1 ELSE 2 END, e.position), '[]'::jsonb)\n INTO v_entries\n FROM public.plg_agenda_turn_entries e\n JOIN public.people p ON p.id = e.professional_id AND p.tenant_id = e.tenant_id\n WHERE e.queue_id = v_queue.id;\n\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'id', ev.id, 'professional_id', ev.professional_id, 'professional_name', p.name,\n 'event_type', ev.event_type, 'origin', ev.origin, 'reason', ev.reason,\n 'occurred_at', ev.occurred_at\n ) ORDER BY ev.occurred_at DESC), '[]'::jsonb)\n INTO v_events\n FROM (\n SELECT * FROM public.plg_agenda_turn_events\n WHERE queue_id = v_queue.id\n AND (occurred_at AT TIME ZONE v_timezone)::date = v_day\n ORDER BY occurred_at DESC LIMIT 50\n ) ev\n JOIN public.people p ON p.id = ev.professional_id AND p.tenant_id = ev.tenant_id;\n\n WITH scheduled AS (\n SELECT DISTINCT s.assignee_id AS professional_id\n FROM public.schedules s\n WHERE s.tenant_id = v_tenant\n AND s.kind = 'working_hours'\n AND s.assignee_id IS NOT NULL\n AND coalesce(s.unit_id, s.location_id) = p_unit_id\n AND (s.valid_from IS NULL OR s.valid_from <= v_day)\n AND (s.valid_to IS NULL OR v_day <= s.valid_to)\n AND (\n (s.specific_date = v_day AND s.is_active)\n OR (\n s.specific_date IS NULL\n AND s.is_active\n AND s.day_of_week = extract(dow FROM v_day)::smallint\n AND public.agenda_week_parity_active(s.valid_from, v_day, s.week_interval)\n AND NOT EXISTS (\n SELECT 1 FROM public.schedules day_override\n WHERE day_override.tenant_id = s.tenant_id\n AND day_override.assignee_id = s.assignee_id\n AND coalesce(day_override.unit_id, day_override.location_id) = p_unit_id\n AND day_override.specific_date = v_day\n )\n )\n )\n ), booked AS (\n SELECT a.assignee_id AS professional_id,\n count(*)::integer AS appointment_count,\n min(a.starts_at) FILTER (WHERE a.starts_at >= now()) AS next_appointment_at\n FROM public.appointments a\n WHERE a.tenant_id = v_tenant\n AND a.assignee_id IS NOT NULL\n AND coalesce(a.unit_id, a.location_id) = p_unit_id\n AND (a.starts_at AT TIME ZONE v_timezone)::date = v_day\n AND coalesce(a.status, '') NOT IN ('cancelled', 'canceled')\n GROUP BY a.assignee_id\n ), candidates AS (\n SELECT professional_id FROM scheduled\n UNION\n SELECT professional_id FROM booked\n UNION\n SELECT e.professional_id FROM public.plg_agenda_turn_entries e WHERE e.queue_id = v_queue.id\n )\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'professional_id', p.id,\n 'professional_name', p.name,\n 'professional_avatar_url', p.avatar_url,\n 'status', coalesce(e.status, 'unavailable'),\n 'position', coalesce(e.position, 0),\n 'last_checkin_at', last_checkin.occurred_at,\n 'is_scheduled', scheduled.professional_id IS NOT NULL,\n 'appointment_count', coalesce(booked.appointment_count, 0),\n 'next_appointment_at', booked.next_appointment_at\n ) ORDER BY\n CASE coalesce(e.status, 'unavailable') WHEN 'available' THEN 0 WHEN 'serving' THEN 1 ELSE 2 END,\n CASE WHEN booked.next_appointment_at IS NOT NULL THEN 0 ELSE 1 END,\n e.position NULLS LAST,\n p.name), '[]'::jsonb)\n INTO v_professionals\n FROM candidates c\n JOIN public.people p ON p.id = c.professional_id AND p.tenant_id = v_tenant AND p.is_active\n LEFT JOIN scheduled ON scheduled.professional_id = p.id\n LEFT JOIN booked ON booked.professional_id = p.id\n LEFT JOIN public.plg_agenda_turn_entries e ON e.queue_id = v_queue.id AND e.professional_id = p.id\n LEFT JOIN LATERAL (\n SELECT ev.occurred_at\n FROM public.plg_agenda_turn_events ev\n WHERE ev.queue_id = v_queue.id\n AND ev.professional_id = p.id\n AND ev.event_type IN ('available', 'override_available')\n AND (ev.occurred_at AT TIME ZONE v_timezone)::date = v_day\n ORDER BY ev.occurred_at DESC\n LIMIT 1\n ) last_checkin ON true;\n\n RETURN jsonb_build_object(\n 'queue_id', v_queue.id,\n 'unit_id', p_unit_id,\n 'queue_name', coalesce(v_queue.name, 'Próximo da vez'),\n 'current_professional_id', v_person,\n 'date', v_day,\n 'professionals', v_professionals,\n 'entries', v_entries,\n 'events', v_events\n );\nEND $$;\n\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_snapshot(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_snapshot(uuid) TO authenticated;\n`\n\nexport const MIGRATION_018_O_AGENDAMENTO_QUE_SE_REPETE = `-- O agendamento que se repete.\n--\n-- \"Repetir\" no agendamento — o que o Google Calendar chama de série. Até aqui\n-- só o BLOQUEIO se repetia (\\`schedules\\` kind='block'); o compromisso de cliente\n-- era sempre avulso, e marcar o retorno de quinze em quinze dias por seis meses\n-- era doze aberturas do mesmo formulário.\n--\n-- ── Por que aqui a ocorrência é LINHA, e no bloqueio não é ──────────────────\n--\n-- A 010 escolheu o contrário, e escolheu certo para o que ela trata: o bloqueio\n-- recorrente é UMA linha por dia da semana com vigência e paridade, expandida\n-- na LEITURA (\\`agenda_busy_blocks\\` no banco, \\`lib/schedule-engine.ts\\` na tela).\n-- O motivo está escrito lá: um almoço de segunda a sexta até o fim do ano são\n-- 1.300 ocorrências de uma coisa que não tem identidade própria — e a grade\n-- ETERNA (248 das 482 do V1 têm \\`valid_to\\` nulo) não se expande de jeito nenhum.\n--\n-- Um compromisso de cliente é o caso oposto, e por três razões concretas que\n-- vivem neste schema:\n--\n-- 1. CADA OCORRÊNCIA É UM FATO SEPARADO. Ela tem status próprio (a de 12/11\n-- foi falta, a de 26/11 foi atendida), check-in próprio (\\`checked_in_at\\`),\n-- e pode ter PEDIDO próprio — \\`order_id\\`, com fatura e recebimento. Nada\n-- disso cabe numa regra: a regra não pode estar \"paga\".\n-- 2. TODO LEITOR JÁ LÊ LINHA. A grade da semana, \\`checkConflict\\`, a fila da\n-- vez (\\`plg_agenda_turn_entries.appointment_id\\`), as confirmações, o\n-- histórico da ficha. Série virtual obrigaria cada um deles a aprender a\n-- expandir uma regra — seis lugares, a mesma regra, divergindo em seis.\n-- 3. A JANELA É FINITA POR NATUREZA. Ninguém marca \"retorno toda semana para\n-- sempre\": marca até uma data ou um número de vezes. O que não se expande\n-- é a grade eterna, e esta não é uma.\n--\n-- Por isso: ocorrência = linha em \\`public.appointments\\`, com \\`series_id\\` REAL.\n-- Não por casamento de campos — a identidade do bloqueio é reconstruída\n-- comparando título + horário + profissional (\\`views/CalendarView.tsx\\`), e é\n-- justamente a parte que não se deve copiar: dois almoços de títulos iguais em\n-- horários iguais são o mesmo bloqueio para aquela heurística.\n--\n-- ── A regra também fica guardada ────────────────────────────────────────────\n--\n-- Materializar não dispensa a regra. Sem ela a tela não sabe dizer \"a cada 2\n-- semanas, 10 vezes\" ao reabrir uma ocorrência, e estender a série depois seria\n-- adivinhar o intervalo pela distância entre duas linhas. O precedente é o\n-- Financeiro: \\`RecurringBill\\` guarda a regra E materializa as parcelas.\n--\n-- ── O que NÃO está aqui ─────────────────────────────────────────────────────\n--\n-- RRULE livre (vários dias da semana, \"primeira terça do mês\" escolhida à mão).\n-- \\`freq\\` é uma lista fechada de três; crescer é acrescentar valor ao CHECK e um\n-- caso no expansor de \\`lib/recurrence.ts\\`, não mudar o modelo.\n--\n-- \"Este e os seguintes\" também não: ele exige PARTIR a regra em duas (a antiga\n-- ganha \\`until_date\\`, a nova nasce do dia editado). Esta migration não impede —\n-- é um INSERT a mais no mesmo formato.\n--\n-- \\`v_appointments\\` NÃO é alargada. O provider já lê \\`public.appointments\\`\n-- direto e funde com a view (\\`data/supabase.ts\\`), justamente porque a view não\n-- expõe \\`resource_id\\` nem \\`unit_id\\`; \\`series_id\\` entra pelo mesmo caminho e não\n-- custa uma redefinição de view que a 007 teve de reescrever inteira.\n--\n-- Idempotente.\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 1. A REGRA\n-- ═══════════════════════════════════════════════════════════════════════════\n\nCREATE TABLE IF NOT EXISTS public.plg_agenda_recurrences (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n -- A cerca da franquia, herdada da primeira ocorrência. Nula é legítima: a\n -- conta de uma unidade só não escolhe unidade, e a 004 estampa \\`unit_id\\` no\n -- agendamento por trigger — a regra nasce antes de a trigger ter rodado.\n unit_id uuid,\n -- \\`daily\\` | \\`weekly\\` | \\`monthly_weekday\\`. Mensal é \"mesmo dia da semana\"\n -- (a 2ª terça), não \"mesmo dia do mês\": é o que o retorno de salão e de\n -- clínica significa, e é o que o Google oferece como preset.\n freq text NOT NULL,\n -- A cada N períodos. 2 + weekly = \"a cada 2 semanas\".\n interval_count integer DEFAULT 1 NOT NULL,\n -- O dia da PRIMEIRA ocorrência, que é a âncora de tudo: o weekday da\n -- semanal e a ordinal do mês da mensal saem dele.\n starts_on date NOT NULL,\n -- \\`HH:MM\\` da série. Guardado para a edição \"todos\" saber que horário\n -- aplicar sem reler uma ocorrência qualquer e torcer para ser a certa.\n time_of_day time without time zone,\n -- O fim, por UMA ponta só. Ver o CHECK abaixo.\n until_date date,\n occurrence_count integer,\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_agenda_recurrences_pkey PRIMARY KEY (id),\n CONSTRAINT plg_agenda_recurrences_freq_chk\n CHECK (freq IN ('daily', 'weekly', 'monthly_weekday')),\n CONSTRAINT plg_agenda_recurrences_interval_chk\n CHECK (interval_count BETWEEN 1 AND 12),\n -- UMA ponta, não duas. Data E contagem juntas dariam duas respostas para\n -- \"quando acaba\", e quem lesse a regra depois não saberia qual valeu.\n CONSTRAINT plg_agenda_recurrences_end_chk CHECK (\n (until_date IS NOT NULL AND occurrence_count IS NULL)\n OR (until_date IS NULL AND occurrence_count IS NOT NULL)\n ),\n -- O MESMO TETO DA TELA, no banco. \\`RECURRENCE_MAX_OCCURRENCES\\` em\n -- \\`lib/recurrence.ts\\` é 60; um teto que só existe no navegador não é teto.\n CONSTRAINT plg_agenda_recurrences_count_chk\n CHECK (occurrence_count IS NULL OR occurrence_count BETWEEN 1 AND 60)\n);\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_recurrences_tenant_fk') THEN\n ALTER TABLE public.plg_agenda_recurrences\n ADD CONSTRAINT plg_agenda_recurrences_tenant_fk\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_agenda_recurrences_unit_fk') THEN\n ALTER TABLE public.plg_agenda_recurrences\n ADD CONSTRAINT plg_agenda_recurrences_unit_fk\n FOREIGN KEY (unit_id) REFERENCES app.units(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_agenda_recurrences_tenant_idx\n ON public.plg_agenda_recurrences (tenant_id, starts_on DESC);\n\nCOMMENT ON TABLE public.plg_agenda_recurrences IS\n 'A regra de uma série de agendamentos (\"Repetir\"). As ocorrências são linhas reais em public.appointments, ligadas por appointments.series_id — ver o cabeçalho da migration 018 para por que aqui se materializa e no bloqueio não.';\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 2. A OCORRÊNCIA APONTA PARA A REGRA\n-- ═══════════════════════════════════════════════════════════════════════════\n\nALTER TABLE public.appointments ADD COLUMN IF NOT EXISTS series_id uuid;\n\nCOMMENT ON COLUMN public.appointments.series_id IS\n 'A série de \"Repetir\" a que esta ocorrência pertence. NULL = agendamento avulso, que é a esmagadora maioria. ON DELETE SET NULL: apagar a regra solta as ocorrências, nunca as apaga.';\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'appointments_series_fk') THEN\n -- SET NULL, e não CASCADE. A 007 já custou uma agenda inteira a um CASCADE\n -- (\"o cascade levou a agenda junto\"): a regra é METADADO da série, e perder\n -- o metadado não pode apagar atendimentos que já aconteceram e já foram\n -- cobrados. Soltar a ocorrência é a perda honesta.\n ALTER TABLE public.appointments\n ADD CONSTRAINT appointments_series_fk\n FOREIGN KEY (series_id) REFERENCES public.plg_agenda_recurrences(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\n-- A pergunta da tela: quais são as outras ocorrências desta série, em ordem.\nCREATE INDEX IF NOT EXISTS idx_appointments_series\n ON public.appointments USING btree (tenant_id, series_id, starts_at)\n WHERE series_id IS NOT NULL;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 3. updated_at\n-- ═══════════════════════════════════════════════════════════════════════════\n\nDROP TRIGGER IF EXISTS plg_agenda_recurrences_updated_at ON public.plg_agenda_recurrences;\nCREATE TRIGGER plg_agenda_recurrences_updated_at\n BEFORE UPDATE ON public.plg_agenda_recurrences\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- 4. Quem enxerga\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- A mesma cerca das ocorrências: quem pode ler a agenda da unidade pode ler a\n-- regra das séries dela. Regra sem unidade (conta de uma unidade só) fica no\n-- alcance de tenant — ela não guarda cliente nem valor, só freq e datas.\n\nALTER TABLE public.plg_agenda_recurrences ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_recurrences FORCE ROW LEVEL SECURITY;\nGRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.plg_agenda_recurrences TO authenticated;\nGRANT ALL ON TABLE public.plg_agenda_recurrences TO service_role;\n\nDROP POLICY IF EXISTS plg_agenda_recurrences_authz_select ON public.plg_agenda_recurrences;\nCREATE POLICY plg_agenda_recurrences_authz_select ON public.plg_agenda_recurrences\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (plg_agenda_recurrences.unit_id IS NULL OR (SELECT app.has_unit(plg_agenda_recurrences.unit_id)))\n AND (SELECT app.has_permission('agenda.read', plg_agenda_recurrences.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_recurrences_authz_insert ON public.plg_agenda_recurrences;\nCREATE POLICY plg_agenda_recurrences_authz_insert ON public.plg_agenda_recurrences\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (plg_agenda_recurrences.unit_id IS NULL OR (SELECT app.has_unit(plg_agenda_recurrences.unit_id)))\n AND (SELECT app.has_permission('agenda.create', plg_agenda_recurrences.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_recurrences_authz_update ON public.plg_agenda_recurrences;\nCREATE POLICY plg_agenda_recurrences_authz_update ON public.plg_agenda_recurrences\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (plg_agenda_recurrences.unit_id IS NULL OR (SELECT app.has_unit(plg_agenda_recurrences.unit_id)))\n AND (SELECT app.has_permission('agenda.edit', plg_agenda_recurrences.unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (plg_agenda_recurrences.unit_id IS NULL OR (SELECT app.has_unit(plg_agenda_recurrences.unit_id)))\n AND (SELECT app.has_permission('agenda.edit', plg_agenda_recurrences.unit_id)));\n\nDROP POLICY IF EXISTS plg_agenda_recurrences_authz_delete ON public.plg_agenda_recurrences;\nCREATE POLICY plg_agenda_recurrences_authz_delete ON public.plg_agenda_recurrences\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (plg_agenda_recurrences.unit_id IS NULL OR (SELECT app.has_unit(plg_agenda_recurrences.unit_id)))\n AND (SELECT app.has_permission('agenda.delete', plg_agenda_recurrences.unit_id)));\n\n-- NÃO entra em \\`scoped_resources\\`, pela mesma razão da 013: aquele registro\n-- pede \\`owner_id\\` porque responde \"de quem é esta linha\". Uma regra de\n-- recorrência não tem dono — ela descreve as ocorrências, e a cerca das\n-- ocorrências já está em \\`appointments\\`.\n`\n\nexport const MIGRATION_019_A_SERIE_NASCE_NUMA_VIAGEM_SO = `-- 019_a_serie_nasce_numa_viagem_so.sql — a série inteira em uma ida ao banco.\n--\n-- A 018 materializou a série, e o cliente a escrevia no laço: uma chamada por\n-- ocorrência, em série, e cada ocorrência custava QUATRO requisições — o RPC de\n-- criação, o update da sala, a sincronização de convidados e o carimbo do\n-- \\`series_id\\`. Uma série de doze terças era ~48 viagens, uma depois da outra,\n-- com a aba aberta o tempo todo. Sessenta (o teto) seriam 240.\n--\n-- O custo não é só espera. Entre a primeira e a última não há transação: fechar\n-- a aba no meio deixa metade da série criada, e metade de uma série é pior que\n-- nenhuma — o cliente recebeu doze datas por telefone.\n--\n-- Então o laço desce para o banco. Uma chamada, uma transação, tudo ou nada.\n--\n-- NÃO É UM SEGUNDO CAMINHO DE ESCRITA. Esta função não insere em\n-- \\`appointments\\`: ela chama \\`agenda_booking_create\\`, que é a mesma porta que o\n-- cliente usava, dentro do laço. Pedido, linhas, duração e idempotência\n-- continuam decididos num lugar só — o que muda é de onde o laço roda. A sala\n-- e os convidados, que o cliente estampava depois porque a assinatura do RPC\n-- está congelada, entram aqui pelo mesmo motivo e na mesma transação.\n\nSET check_function_bodies = false;\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- A série, de uma vez\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- \\`p_days\\` são DIAS locais (\\`YYYY-MM-DD\\`) já expandidos por\n-- \\`lib/recurrence.expandRecurrence\\`. A expansão fica no TypeScript de\n-- propósito: é a parte que erra se algo errar, e teste de aritmética de datas\n-- não deve precisar de banco. O que o banco faz é escrever.\n--\n-- \\`p_time_of_day\\` chega como texto \\`HH:MM\\` e o instante é montado AQUI, dia a\n-- dia, no fuso da sessão. Mandar 12 timestamps prontos daria no mesmo até a\n-- série cruzar um horário de verão — e aí a oitava ocorrência cairia uma hora\n-- fora sem ninguém ter mexido nela.\nCREATE OR REPLACE FUNCTION public.agenda_series_create(\n p_days date[],\n p_time_of_day text,\n p_rule jsonb,\n p_lines jsonb DEFAULT '[]'::jsonb,\n p_party_id uuid DEFAULT NULL,\n p_assignee_id uuid DEFAULT NULL,\n p_unit_id uuid DEFAULT NULL,\n p_location_id uuid DEFAULT NULL,\n p_resource_id uuid DEFAULT NULL,\n p_kind text DEFAULT 'appointment',\n p_notes text DEFAULT NULL,\n p_metadata jsonb DEFAULT '{}'::jsonb,\n p_attendee_ids uuid[] DEFAULT NULL\n) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_series uuid;\n v_day date;\n v_starts timestamptz;\n v_res jsonb;\n v_booking uuid;\n v_ids uuid[] := '{}';\n v_failed jsonb := '[]'::jsonb;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'agenda_series_create: no active tenant' USING ERRCODE = '42501';\n END IF;\n -- A MESMA permissão de uma ocorrência. Criar doze não é um poder novo; é o\n -- mesmo gesto doze vezes, e \\`agenda_booking_create\\` vai conferir de novo em\n -- cada uma. Conferir aqui é para a série falhar ANTES de escrever a regra.\n IF NOT (select app.has_permission('agenda.create', p_unit_id)) THEN\n RAISE EXCEPTION 'agenda_series_create: agenda.create is required' USING ERRCODE = '42501';\n END IF;\n IF p_days IS NULL OR array_length(p_days, 1) IS NULL THEN\n RAISE EXCEPTION 'agenda_series_create: a série não tem nenhuma data' USING ERRCODE = '22023';\n END IF;\n -- O MESMO teto do expansor (\\`RECURRENCE_MAX_OCCURRENCES\\`). Aqui porque o\n -- cliente não é a única porta: um agente ou um script chamam este RPC direto,\n -- e um \\`p_days\\` de mil datas é uma transação que segura a tabela.\n IF array_length(p_days, 1) > 60 THEN\n RAISE EXCEPTION 'agenda_series_create: uma série tem no máximo 60 ocorrências' USING ERRCODE = '22023';\n END IF;\n\n INSERT INTO public.plg_agenda_recurrences (\n tenant_id, unit_id, freq, interval_count, starts_on, time_of_day, until_date, occurrence_count\n ) VALUES (\n v_tenant, p_unit_id,\n p_rule ->> 'freq',\n coalesce((p_rule ->> 'interval')::integer, 1),\n p_days[1],\n -- \\`time_of_day\\` é \\`time\\`; o parâmetro chega como \\`HH:MM\\` de texto porque é\n -- assim que a tela o tem. O cast é explícito para o erro ser aqui, e não\n -- uma série recusada inteira por causa de dois pontos.\n p_time_of_day::time,\n nullif(p_rule ->> 'until', '')::date,\n CASE WHEN nullif(p_rule ->> 'until', '') IS NULL\n THEN coalesce((p_rule ->> 'count')::integer, array_length(p_days, 1))\n ELSE NULL END\n )\n RETURNING id INTO v_series;\n\n FOREACH v_day IN ARRAY p_days LOOP\n v_starts := (v_day::text || ' ' || p_time_of_day)::timestamp AT TIME ZONE current_setting('TimeZone');\n\n BEGIN\n v_res := public.agenda_booking_create(\n p_starts_at => v_starts,\n p_ends_at => NULL,\n p_lines => p_lines,\n p_party_id => p_party_id,\n p_assignee_id => p_assignee_id,\n p_unit_id => p_unit_id,\n p_location_id => p_location_id,\n p_kind => p_kind,\n p_notes => p_notes,\n p_metadata => p_metadata);\n\n v_booking := (v_res ->> 'appointment_id')::uuid;\n\n -- Série e sala no mesmo UPDATE: as duas são colunas que a porta\n -- congelada de \\`agenda_booking_create\\` não escreve, e separá-las seria\n -- escrever a mesma linha duas vezes.\n UPDATE public.appointments\n SET series_id = v_series,\n resource_id = coalesce(p_resource_id, resource_id)\n WHERE id = v_booking;\n\n IF p_attendee_ids IS NOT NULL AND array_length(p_attendee_ids, 1) > 0 THEN\n INSERT INTO public.appointment_attendees (tenant_id, booking_id, person_id, role)\n SELECT v_tenant, v_booking, a, 'required'\n FROM unnest(p_attendee_ids) a\n ON CONFLICT DO NOTHING;\n END IF;\n\n v_ids := v_ids || v_booking;\n EXCEPTION WHEN OTHERS THEN\n -- UMA DATA QUE NÃO ENTROU NÃO DERRUBA AS OUTRAS, e também não some: o\n -- bloco devolve o motivo e a tela conta quantas nasceram. É a mesma\n -- regra do conflito — avisa, não impede —, e o \\`EXCEPTION\\` aninhado\n -- desfaz só esta iteração, não a série.\n v_failed := v_failed || jsonb_build_object('date', v_day::text, 'message', SQLERRM);\n END;\n END LOOP;\n\n RETURN jsonb_build_object(\n 'series_id', v_series,\n 'booking_ids', to_jsonb(v_ids),\n 'failed', v_failed);\nEND $$;\n\nREVOKE ALL ON FUNCTION public.agenda_series_create(date[], text, jsonb, jsonb, uuid, uuid, uuid, uuid, uuid, text, text, jsonb, uuid[]) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agenda_series_create(date[], text, jsonb, jsonb, uuid, uuid, uuid, uuid, uuid, text, text, jsonb, uuid[]) TO authenticated, service_role;\n\nCOMMENT ON FUNCTION public.agenda_series_create(date[], text, jsonb, jsonb, uuid, uuid, uuid, uuid, uuid, text, text, jsonb, uuid[]) IS\n 'Cria a regra de recorrência e TODAS as ocorrências numa transação, chamando agenda_booking_create por dia. Substitui o laço de ~4 requisições por ocorrência que o cliente fazia.';\n\n-- ═══════════════════════════════════════════════════════════════════════════\n-- Mudar a série inteira\n-- ═══════════════════════════════════════════════════════════════════════════\n--\n-- Só o que é DA SÉRIE: o horário, o profissional, a sala, o local, a nota e o\n-- status. O DIA de cada ocorrência é dela — mandar um \\`starts_at\\` único para\n-- todas empilharia a série num dia só, que é o defeito que \\`planSeriesEdit\\`\n-- existe para não deixar acontecer no cliente. Aqui é a mesma regra, do lado\n-- do banco, para quem chamar direto.\n--\n-- AS LINHAS (serviços) NÃO ENTRAM. Reescrevê-las pede saber se o agendamento\n-- tem pedido ou não — a regra que \\`updateBooking\\` carrega no TypeScript — e\n-- duplicá-la aqui seria o segundo caminho de escrita que esta migration\n-- recusa. Quem muda serviço numa série inteira continua indo pelo cliente.\nCREATE OR REPLACE FUNCTION public.agenda_series_update(\n p_series_id uuid,\n p_time_of_day text,\n p_assignee_id uuid DEFAULT NULL,\n p_location_id uuid DEFAULT NULL,\n p_resource_id uuid DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_touch text[] DEFAULT '{}'\n) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_unit uuid;\n v_count integer;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'agenda_series_update: no active tenant' USING ERRCODE = '42501';\n END IF;\n\n SELECT r.unit_id INTO v_unit FROM public.plg_agenda_recurrences r\n WHERE r.id = p_series_id AND r.tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'agenda_series_update: série % não encontrada', p_series_id USING ERRCODE = 'P0002';\n END IF;\n IF NOT (select app.has_permission('agenda.edit', v_unit)) THEN\n RAISE EXCEPTION 'agenda_series_update: agenda.edit is required' USING ERRCODE = '42501';\n END IF;\n\n -- \\`p_touch\\` diz QUAIS campos a tela mexeu. Sem ele, um NULL seria ambíguo\n -- entre \"não mexi\" e \"esvaziei\" — e desatribuir o profissional de uma série\n -- é uma decisão tão legítima quanto trocá-lo.\n UPDATE public.appointments a\n SET starts_at = ((a.starts_at AT TIME ZONE current_setting('TimeZone'))::date::text\n || ' ' || p_time_of_day)::timestamp AT TIME ZONE current_setting('TimeZone'),\n ends_at = CASE WHEN a.ends_at IS NULL THEN NULL ELSE\n ((a.starts_at AT TIME ZONE current_setting('TimeZone'))::date::text\n || ' ' || p_time_of_day)::timestamp AT TIME ZONE current_setting('TimeZone')\n + (a.ends_at - a.starts_at) END,\n assignee_id = CASE WHEN 'assigneeId' = ANY(p_touch) THEN p_assignee_id ELSE a.assignee_id END,\n location_id = CASE WHEN 'locationId' = ANY(p_touch) THEN p_location_id ELSE a.location_id END,\n resource_id = CASE WHEN 'resourceId' = ANY(p_touch) THEN p_resource_id ELSE a.resource_id END,\n notes = CASE WHEN 'notes' = ANY(p_touch) THEN p_notes ELSE a.notes END,\n status = CASE WHEN 'status' = ANY(p_touch) THEN p_status ELSE a.status END,\n updated_at = now()\n WHERE a.series_id = p_series_id\n AND a.tenant_id = v_tenant;\n GET DIAGNOSTICS v_count = ROW_COUNT;\n\n UPDATE public.plg_agenda_recurrences\n SET time_of_day = p_time_of_day::time, updated_at = now()\n WHERE id = p_series_id;\n\n RETURN jsonb_build_object('updated', v_count);\nEND $$;\n\nREVOKE ALL ON FUNCTION public.agenda_series_update(uuid, text, uuid, uuid, uuid, text, text, text[]) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agenda_series_update(uuid, text, uuid, uuid, uuid, text, text, text[]) TO authenticated, service_role;\n\nCOMMENT ON FUNCTION public.agenda_series_update(uuid, text, uuid, uuid, uuid, text, text, text[]) IS\n 'Aplica a mesma mudança a todas as ocorrências da série num UPDATE só, preservando o DIA de cada uma. Não toca nas linhas de serviço — essa regra vive no cliente.';\n`\n\nexport const MIGRATION_020_A_LISTA_DA_VEZ_SE_LIGA_NO_CADASTRO_DA_UNIDADE = `-- A lista da vez se liga no cadastro da unidade.\n--\n-- No V1 o interruptor \"Ativar lista da vez nesta unidade\" e o raio da cerca\n-- moram no cadastro da empresa (V1: companies.turn_queue_enabled,\n-- turn_queue_radius_m). Em 016 a fila nasceu sem tela de configuração: a linha\n-- surge sozinha, LIGADA, no primeiro \"estou disponível\", e o app só pode lê-la.\n--\n-- Esta RPC é a porta que faltava — a aba \"Lista da vez\" da unidade grava por\n-- ela. Quem configura é quem administra a filial (settings.manage NA unidade).\n-- A coordenada continua sendo a do endereço da unidade (142); não há segunda\n-- latitude aqui.\n--\n-- Chave que conecta: plg_agenda_turn_queues.unit_id = app.units.id\n-- (UNIQUE tenant_id, unit_id, queue_key). Idempotente.\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_configure(\n p_unit_id uuid,\n p_enabled boolean,\n p_geofence_required boolean DEFAULT NULL,\n p_radius_meters integer DEFAULT NULL\n)\nRETURNS public.plg_agenda_turn_queues\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $function$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_row public.plg_agenda_turn_queues;\nBEGIN\n IF v_tenant IS NULL OR auth.uid() IS NULL THEN\n RAISE EXCEPTION 'agenda_turn_queue_configure: no session or no active tenant' USING ERRCODE = '42501';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant) THEN\n RAISE EXCEPTION 'agenda_turn_queue_configure: unit does not belong to this tenant' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('settings.manage', p_unit_id) THEN\n RAISE EXCEPTION 'agenda_turn_queue_configure: settings.manage is required on this unit' USING ERRCODE = '42501';\n END IF;\n IF p_radius_meters IS NOT NULL AND (p_radius_meters < 10 OR p_radius_meters > 1000) THEN\n RAISE EXCEPTION 'agenda_turn_queue_configure: radius must be between 10 and 1000 meters' USING ERRCODE = '22023';\n END IF;\n\n INSERT INTO public.plg_agenda_turn_queues AS q (tenant_id, unit_id, is_active, geofence_required, geofence_radius_meters)\n VALUES (v_tenant, p_unit_id, coalesce(p_enabled, false), coalesce(p_geofence_required, true), coalesce(p_radius_meters, 80))\n ON CONFLICT (tenant_id, unit_id, queue_key) DO UPDATE\n SET is_active = coalesce(p_enabled, q.is_active),\n geofence_required = coalesce(p_geofence_required, q.geofence_required),\n geofence_radius_meters = coalesce(p_radius_meters, q.geofence_radius_meters),\n updated_at = now()\n RETURNING * INTO v_row;\n RETURN v_row;\nEND $function$;\n\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_configure(uuid, boolean, boolean, integer) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_configure(uuid, boolean, boolean, integer) TO authenticated, service_role;\n`\n\nexport const MIGRATION_021_A_VEZ_POR_AREA = `-- A lista da vez do V1 (BeautySoft, 20261114100000 + 20261114110000): uma fila\n-- por ÁREA dentro da unidade, ausência curta que guarda o lugar, a recepção\n-- conduzindo a fila (chamar, começou, terminou, mover, tirar), serviço rápido\n-- que não tira da vez e a fila recomeçando todo dia.\n--\n-- Áreas, área fixada e serviços rápidos são da REDE (como no V1); o resto é da\n-- unidade e mora na linha de plg_agenda_turn_queues desde 016/020.\n-- Aditivo e idempotente: nada some, 016/017/020 continuam respondendo.\n\n-- 1) Configuração da rede -------------------------------------------------------\nCREATE TABLE IF NOT EXISTS public.plg_agenda_turn_areas (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n key text NOT NULL CHECK (key ~ '^[a-z0-9_]{1,30}$'),\n name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 40),\n color text NOT NULL DEFAULT 'slate'\n CHECK (color IN ('amber', 'green', 'blue', 'pink', 'purple', 'orange', 'teal', 'slate')),\n keywords text[] NOT NULL DEFAULT '{}',\n sort_order integer NOT NULL DEFAULT 0,\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (tenant_id, key)\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_agenda_turn_professional_areas (\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n professional_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n area_key text NOT NULL,\n updated_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, professional_id)\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_agenda_turn_quick_services (\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,\n created_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, product_id)\n);\n\nALTER TABLE public.plg_agenda_turn_areas ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_areas FORCE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_professional_areas ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_professional_areas FORCE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_quick_services ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_agenda_turn_quick_services FORCE ROW LEVEL SECURITY;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_areas' AND policyname = 'plg_agenda_turn_areas_read') THEN\n CREATE POLICY plg_agenda_turn_areas_read ON public.plg_agenda_turn_areas\n FOR SELECT TO authenticated USING (tenant_id = app.current_tenant_id());\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_professional_areas' AND policyname = 'plg_agenda_turn_professional_areas_read') THEN\n CREATE POLICY plg_agenda_turn_professional_areas_read ON public.plg_agenda_turn_professional_areas\n FOR SELECT TO authenticated USING (tenant_id = app.current_tenant_id());\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'plg_agenda_turn_quick_services' AND policyname = 'plg_agenda_turn_quick_services_read') THEN\n CREATE POLICY plg_agenda_turn_quick_services_read ON public.plg_agenda_turn_quick_services\n FOR SELECT TO authenticated USING (tenant_id = app.current_tenant_id());\n END IF;\nEND $$;\n\nREVOKE ALL ON public.plg_agenda_turn_areas, public.plg_agenda_turn_professional_areas, public.plg_agenda_turn_quick_services FROM PUBLIC, anon, authenticated;\nGRANT SELECT ON public.plg_agenda_turn_areas, public.plg_agenda_turn_professional_areas, public.plg_agenda_turn_quick_services TO authenticated;\n\n-- 2) Unidade, entradas e trilha -------------------------------------------------\nALTER TABLE public.plg_agenda_turn_queues\n ADD COLUMN IF NOT EXISTS follows_appointments boolean NOT NULL DEFAULT true,\n ADD COLUMN IF NOT EXISTS self_checkin boolean NOT NULL DEFAULT true;\n\nALTER TABLE public.plg_agenda_turn_entries\n ADD COLUMN IF NOT EXISTS area_key text,\n ADD COLUMN IF NOT EXISTS away_reason text,\n ADD COLUMN IF NOT EXISTS joined_at timestamptz;\n\nALTER TABLE public.plg_agenda_turn_events\n ADD COLUMN IF NOT EXISTS area_key text;\n\n-- 'away' = saiu e guarda o lugar. 'unavailable' segue sendo \"fora da fila\".\nALTER TABLE public.plg_agenda_turn_entries DROP CONSTRAINT IF EXISTS plg_agenda_turn_entries_status_check;\nALTER TABLE public.plg_agenda_turn_entries ADD CONSTRAINT plg_agenda_turn_entries_status_check\n CHECK (status IN ('available', 'away', 'serving', 'unavailable'));\n\nUPDATE public.plg_agenda_turn_entries\n SET joined_at = coalesce(available_at, state_changed_at)\n WHERE joined_at IS NULL AND status <> 'unavailable';\n\n-- 3) Helpers ----------------------------------------------------------------------\nCREATE OR REPLACE FUNCTION public.agenda_turn__norm(p text)\nRETURNS text LANGUAGE sql IMMUTABLE SET search_path = '' AS $$\n SELECT translate(lower(coalesce(p, '')),\n 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn');\n$$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn__area_defs(p_tenant uuid)\nRETURNS TABLE(key text, name text, color text, keywords text[], sort_order integer)\nLANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n WITH cad AS (\n SELECT a.key, a.name, a.color, a.keywords, a.sort_order\n FROM public.plg_agenda_turn_areas a\n WHERE a.tenant_id = p_tenant AND a.is_active\n ), padrao(key, name, color, keywords, sort_order) AS (\n VALUES\n ('cabelo', 'Cabelo', 'amber', ARRAY['cabel', 'color', 'escov', 'penteado', 'barb'], 1),\n ('unhas', 'Unhas', 'green', ARRAY['manicur', 'pedicur', 'unha'], 2),\n ('estetica', 'Estética', 'blue', ARRAY['estet', 'depil', 'sobrancel', 'massag', 'cili', 'maqui'], 3)\n )\n SELECT * FROM cad\n UNION ALL\n SELECT * FROM padrao WHERE NOT EXISTS (SELECT 1 FROM cad)\n UNION ALL\n SELECT 'geral', 'Outros', 'slate', ARRAY[]::text[], 99\n WHERE NOT EXISTS (SELECT 1 FROM cad WHERE cad.key = 'geral');\n$$;\n\n-- The specialty arrives as a JSON array string ('[\"Cabelereiro\",\"Colorista\"]')\n-- from the V1 ETL, or as plain text typed in the V2 form.\nCREATE OR REPLACE FUNCTION public.agenda_turn__auto_area_of(p_tenant uuid, p_person uuid)\nRETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_spec text;\n v_tokens text[];\n v_key text;\nBEGIN\n SELECT btrim(coalesce(nullif(p.metadata->'professional'->>'specialty', ''),\n nullif(p.metadata->'professional'->>'job_title', ''),\n nullif(p.custom_fields->>'profession', ''), ''))\n INTO v_spec FROM public.people p WHERE p.id = p_person AND p.tenant_id = p_tenant;\n IF coalesce(v_spec, '') = '' THEN RETURN 'geral'; END IF;\n BEGIN\n IF left(v_spec, 1) = '[' THEN\n SELECT array_agg(public.agenda_turn__norm(x)) INTO v_tokens FROM jsonb_array_elements_text(v_spec::jsonb) x;\n END IF;\n EXCEPTION WHEN others THEN v_tokens := NULL;\n END;\n IF v_tokens IS NULL THEN v_tokens := ARRAY[public.agenda_turn__norm(v_spec)]; END IF;\n\n SELECT d.key INTO v_key FROM public.agenda_turn__area_defs(p_tenant) d\n WHERE EXISTS (SELECT 1 FROM unnest(d.keywords) pat, unnest(v_tokens) tok\n WHERE pat <> '' AND tok LIKE '%' || public.agenda_turn__norm(pat) || '%')\n ORDER BY d.sort_order, d.key LIMIT 1;\n RETURN coalesce(v_key, 'geral');\nEND $$;\n\n-- A pinned area wins over the specialty, as long as that area still exists.\nCREATE OR REPLACE FUNCTION public.agenda_turn__area_of(p_tenant uuid, p_person uuid)\nRETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT coalesce(\n (SELECT pa.area_key FROM public.plg_agenda_turn_professional_areas pa\n WHERE pa.tenant_id = p_tenant AND pa.professional_id = p_person\n AND EXISTS (SELECT 1 FROM public.agenda_turn__area_defs(p_tenant) d WHERE d.key = pa.area_key)),\n public.agenda_turn__auto_area_of(p_tenant, p_person));\n$$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn__tz(p_tenant uuid, p_unit uuid)\nRETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT coalesce(\n (SELECT nullif(u.timezone, '') FROM public.v_units u WHERE u.id = p_unit),\n (SELECT nullif(t.settings->>'timezone', '') FROM public.tenants t WHERE t.id = p_tenant),\n 'America/Sao_Paulo');\n$$;\n\n-- Yesterday's queue does not survive the night: entries from a previous local\n-- day go out of the queue and leave a 'reset' in the trail.\nCREATE OR REPLACE FUNCTION public.agenda_turn__purge_stale(p_queue uuid, p_tz text)\nRETURNS void LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n WITH velhas AS (\n UPDATE public.plg_agenda_turn_entries e\n SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,\n state_changed_at = now(), updated_at = now()\n WHERE e.queue_id = p_queue AND e.status <> 'unavailable'\n AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE p_tz)::date\n < (now() AT TIME ZONE p_tz)::date\n RETURNING e.tenant_id, e.unit_id, e.professional_id, e.area_key, e.position\n )\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, area_key, payload)\n SELECT v.tenant_id, p_queue, v.unit_id, v.professional_id, 'reset', 'automatic', v.area_key,\n jsonb_build_object('from_position', v.position)\n FROM velhas v;\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn__renumber(p_queue uuid, p_area text)\nRETURNS void LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\n UPDATE public.plg_agenda_turn_entries e SET position = o.rn\n FROM (SELECT id, row_number() OVER (ORDER BY position, joined_at, id) AS rn\n FROM public.plg_agenda_turn_entries\n WHERE queue_id = p_queue AND area_key IS NOT DISTINCT FROM p_area\n AND status IN ('available', 'away')) o\n WHERE e.id = o.id AND e.position IS DISTINCT FROM o.rn;\n$$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn__next_position(p_queue uuid, p_area text)\nRETURNS bigint LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT coalesce(max(position), 0) + 1 FROM public.plg_agenda_turn_entries\n WHERE queue_id = p_queue AND area_key IS NOT DISTINCT FROM p_area AND status IN ('available', 'away');\n$$;\n\n-- Bookable people of this unit (or of the whole network), as the agenda shows them.\nCREATE OR REPLACE FUNCTION public.agenda_turn__in_unit(p_tenant uuid, p_unit uuid, p_person uuid)\nRETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT EXISTS (\n SELECT 1 FROM public.v_bookable_people b\n WHERE b.id = p_person AND b.tenant_id = p_tenant AND b.is_active AND b.is_bookable\n AND b.kind <> 'customer'\n AND (b.unit_ids IS NULL OR p_unit = ANY (b.unit_ids)));\n$$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn__queue(p_tenant uuid, p_unit uuid)\nRETURNS public.plg_agenda_turn_queues LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT * FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = p_tenant AND q.unit_id = p_unit AND q.queue_key = 'walk_in';\n$$;\n\n-- 4) Quadro -------------------------------------------------------------------------\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_board(p_unit_id uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues;\n v_tz text;\n v_day date;\n v_lat numeric;\n v_lng numeric;\n v_result jsonb;\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF p_unit_id IS NULL THEN RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023'; END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';\n END IF;\n v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);\n v_tz := public.agenda_turn__tz(v_tenant, p_unit_id);\n v_day := (now() AT TIME ZONE v_tz)::date;\n SELECT u.latitude, u.longitude INTO v_lat, v_lng FROM public.v_units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;\n\n WITH ent AS (\n SELECT e.*, row_number() OVER (PARTITION BY e.area_key, (e.status IN ('available', 'away'))\n ORDER BY e.position, e.joined_at, e.id) AS rnk\n FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.status IN ('available', 'away', 'serving')\n AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = v_day\n ), appts AS (\n SELECT a.assignee_id AS professional_id, count(*)::int AS n,\n min(a.starts_at) FILTER (WHERE a.starts_at >= now() AND a.status NOT IN ('completed', 'cancelled', 'no_show')) AS proximo\n FROM public.appointments a\n WHERE a.tenant_id = v_tenant AND a.assignee_id IS NOT NULL\n AND coalesce(a.unit_id, a.location_id) = p_unit_id\n AND (a.starts_at AT TIME ZONE v_tz)::date = v_day\n AND a.status <> 'cancelled'\n GROUP BY 1\n ), roster AS (\n SELECT p.id, p.name, p.avatar_url, public.agenda_turn__area_of(v_tenant, p.id) AS area\n FROM public.people p\n WHERE p.tenant_id = v_tenant AND p.is_active\n AND (public.agenda_turn__in_unit(v_tenant, p_unit_id, p.id)\n OR EXISTS (SELECT 1 FROM appts t WHERE t.professional_id = p.id))\n ), served AS (\n SELECT ev.professional_id,\n count(*) FILTER (WHERE ev.event_type IN ('started', 'advanced_to_service'))::int AS atendimentos\n FROM public.plg_agenda_turn_events ev\n WHERE ev.queue_id = v_queue.id AND (ev.occurred_at AT TIME ZONE v_tz)::date = v_day\n GROUP BY 1\n )\n SELECT jsonb_build_object(\n 'unitId', p_unit_id,\n 'today', v_day,\n 'enabled', coalesce(v_queue.is_active, false),\n 'selfCheckin', coalesce(v_queue.self_checkin, true),\n 'auto', coalesce(v_queue.follows_appointments, true),\n 'requireLocation', coalesce(v_queue.geofence_required, false),\n 'locationConfigured', v_lat IS NOT NULL AND v_lng IS NOT NULL,\n 'areas', coalesce((SELECT jsonb_agg(jsonb_build_object('key', d.key, 'name', d.name, 'color', d.color) ORDER BY d.sort_order, d.key)\n FROM public.agenda_turn__area_defs(v_tenant) d), '[]'::jsonb),\n 'entries', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'professionalId', e.professional_id, 'name', p.name, 'avatarUrl', p.avatar_url,\n 'area', coalesce(e.area_key, 'geral'), 'status', e.status,\n 'rank', CASE WHEN e.status IN ('available', 'away') THEN e.rnk END,\n 'awayReason', e.away_reason, 'since', e.state_changed_at, 'joinedAt', e.joined_at,\n 'appointmentId', e.appointment_id,\n 'servedToday', coalesce(s.atendimentos, 0)\n ) ORDER BY e.area_key, (e.status = 'serving'), e.rnk)\n FROM ent e JOIN public.people p ON p.id = e.professional_id\n LEFT JOIN served s ON s.professional_id = e.professional_id), '[]'::jsonb),\n 'roster', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'professionalId', r.id, 'name', r.name, 'avatarUrl', r.avatar_url, 'area', r.area,\n 'appointmentsToday', coalesce(t.n, 0), 'nextAppointmentAt', t.proximo,\n 'servedToday', coalesce(s.atendimentos, 0)) ORDER BY r.name)\n FROM roster r\n LEFT JOIN appts t ON t.professional_id = r.id\n LEFT JOIN served s ON s.professional_id = r.id\n WHERE NOT EXISTS (SELECT 1 FROM ent e WHERE e.professional_id = r.id)), '[]'::jsonb),\n 'events', coalesce((\n SELECT jsonb_agg(x.item ORDER BY x.at DESC) FROM (\n SELECT jsonb_build_object('id', ev.id, 'professionalId', ev.professional_id, 'name', ep.name,\n 'type', ev.event_type, 'origin', ev.origin, 'area', ev.area_key,\n 'reason', coalesce(ev.reason, ev.payload->>'reason'), 'createdAt', ev.occurred_at) AS item,\n ev.occurred_at AS at\n FROM public.plg_agenda_turn_events ev\n JOIN public.people ep ON ep.id = ev.professional_id\n WHERE ev.queue_id = v_queue.id AND (ev.occurred_at AT TIME ZONE v_tz)::date = v_day\n ORDER BY ev.occurred_at DESC LIMIT 60) x), '[]'::jsonb)\n ) INTO v_result;\n RETURN v_result;\nEND $$;\n\n-- 5) Ações da recepção --------------------------------------------------------------\n-- join → create · leave → delete · away/back/move/start/finish/skip/call → edit\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_act(\n p_unit_id uuid,\n p_professional_id uuid,\n p_action text,\n p_position integer DEFAULT NULL,\n p_reason text DEFAULT NULL\n) RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues;\n v_entry public.plg_agenda_turn_entries;\n v_need text := CASE p_action WHEN 'join' THEN 'create' WHEN 'leave' THEN 'delete' ELSE 'edit' END;\n v_reason text := nullif(btrim(coalesce(p_reason, '')), '');\n v_area text;\n v_pos bigint;\n v_ids uuid[];\n v_users uuid[];\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF p_action NOT IN ('join', 'leave', 'away', 'back', 'move', 'start', 'finish', 'skip', 'call') THEN\n RAISE EXCEPTION 'turn_queue_invalid_action:%', p_action USING ERRCODE = '22023';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.' || v_need, p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.% is required', v_need USING ERRCODE = '42501';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = p_professional_id AND p.tenant_id = v_tenant AND p.is_active) THEN\n RAISE EXCEPTION 'turn_queue_invalid_professional' USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' FOR UPDATE;\n IF v_queue.id IS NULL OR NOT v_queue.is_active THEN\n RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '22023';\n END IF;\n PERFORM public.agenda_turn__purge_stale(v_queue.id, public.agenda_turn__tz(v_tenant, p_unit_id));\n\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.professional_id = p_professional_id;\n IF v_entry.id IS NOT NULL AND v_entry.status = 'unavailable' THEN v_entry := NULL; END IF;\n\n IF p_action = 'join' THEN\n IF v_entry.id IS NOT NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n v_area := public.agenda_turn__area_of(v_tenant, p_professional_id);\n v_pos := public.agenda_turn__next_position(v_queue.id, v_area);\n INSERT INTO public.plg_agenda_turn_entries\n (tenant_id, queue_id, unit_id, professional_id, status, position, area_key, joined_at, available_at, state_changed_at, appointment_id, away_reason, updated_at)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'available', v_pos, v_area, now(), now(), now(), NULL, NULL, now())\n ON CONFLICT (queue_id, professional_id) DO UPDATE SET\n status = 'available', position = v_pos, area_key = v_area, joined_at = now(), available_at = now(),\n state_changed_at = now(), appointment_id = NULL, away_reason = NULL, updated_at = now();\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'joined', 'frontdesk', auth.uid(), v_area, jsonb_build_object('to_position', v_pos));\n RETURN jsonb_build_object('ok', true, 'changed', true, 'area', v_area, 'position', v_pos);\n END IF;\n\n IF p_action = 'call' THEN\n SELECT array_agg(DISTINCT m.user_id) INTO v_users FROM app.memberships m\n WHERE m.tenant_id = v_tenant AND m.person_id = p_professional_id AND m.active;\n IF v_users IS NOT NULL THEN\n INSERT INTO public.notifications (tenant_id, user_id, title, body, type, link, subject_type, subject_id)\n SELECT v_tenant, u, 'Cliente para você na recepção', coalesce(v_reason, 'Você é o próximo da vez.'),\n 'turn_queue_call', '#/agenda', 'person', p_professional_id\n FROM unnest(v_users) u;\n END IF;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'called', 'frontdesk', auth.uid(),\n coalesce(v_entry.area_key, public.agenda_turn__area_of(v_tenant, p_professional_id)), v_reason,\n jsonb_build_object('notified', v_users IS NOT NULL));\n RETURN jsonb_build_object('ok', true, 'changed', true, 'notified', v_users IS NOT NULL);\n END IF;\n\n IF v_entry.id IS NULL THEN RAISE EXCEPTION 'turn_queue_not_in_queue' USING ERRCODE = '22023'; END IF;\n\n IF p_action = 'leave' THEN\n UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'left', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('from_position', v_entry.position));\n\n ELSIF p_action = 'away' THEN\n IF v_entry.status = 'serving' THEN RAISE EXCEPTION 'turn_queue_in_service' USING ERRCODE = '22023'; END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'away', away_reason = coalesce(v_reason, 'Ausente'),\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'away', 'frontdesk', auth.uid(), v_entry.area_key, coalesce(v_reason, 'Ausente'),\n jsonb_build_object('from_position', v_entry.position));\n\n ELSIF p_action = 'back' THEN\n IF v_entry.status <> 'away' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'back', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_entry.position));\n\n ELSIF p_action = 'move' THEN\n IF v_entry.status = 'serving' THEN RAISE EXCEPTION 'turn_queue_in_service' USING ERRCODE = '22023'; END IF;\n IF p_position IS NULL OR p_position < 1 THEN RAISE EXCEPTION 'turn_queue_invalid_position' USING ERRCODE = '22023'; END IF;\n SELECT array_agg(id ORDER BY position, joined_at, id) INTO v_ids FROM public.plg_agenda_turn_entries\n WHERE queue_id = v_queue.id AND area_key IS NOT DISTINCT FROM v_entry.area_key\n AND status IN ('available', 'away') AND id <> v_entry.id;\n v_ids := coalesce(v_ids, ARRAY[]::uuid[]);\n v_pos := least(p_position, coalesce(array_length(v_ids, 1), 0) + 1);\n v_ids := v_ids[1:v_pos - 1] || v_entry.id || v_ids[v_pos:];\n UPDATE public.plg_agenda_turn_entries e SET position = o.ord, updated_at = now()\n FROM unnest(v_ids) WITH ORDINALITY o(id, ord) WHERE e.id = o.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'moved', 'frontdesk', auth.uid(), v_entry.area_key,\n jsonb_build_object('from_position', v_entry.position, 'to_position', v_pos));\n\n ELSIF p_action = 'start' THEN\n IF v_entry.status = 'serving' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'serving', away_reason = NULL, previous_position = position,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'started', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('from_position', v_entry.position));\n\n ELSIF p_action = 'finish' THEN\n v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);\n UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL, appointment_id = NULL,\n position = v_pos, available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'finished', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_pos));\n\n ELSIF p_action = 'skip' THEN\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'skipped', 'frontdesk', auth.uid(), v_entry.area_key, v_reason,\n jsonb_build_object('from_position', v_entry.position));\n END IF;\n\n RETURN jsonb_build_object('ok', true, 'changed', true);\nEND $$;\n\n-- 6) O próprio profissional (\"Cheguei\") -------------------------------------------\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_my_state(p_unit_id uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_person uuid;\n v_queue public.plg_agenda_turn_queues;\n v_entry public.plg_agenda_turn_entries;\n v_tz text;\n v_rank bigint;\nBEGIN\n IF v_tenant IS NULL OR auth.uid() IS NULL OR p_unit_id IS NULL THEN RETURN NULL; END IF;\n SELECT m.person_id INTO v_person FROM app.memberships m\n WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.person_id IS NOT NULL LIMIT 1;\n IF v_person IS NULL OR NOT public.agenda_turn__in_unit(v_tenant, p_unit_id, v_person) THEN RETURN NULL; END IF;\n v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);\n IF v_queue.id IS NULL OR NOT v_queue.is_active THEN RETURN NULL; END IF;\n v_tz := public.agenda_turn__tz(v_tenant, p_unit_id);\n\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.professional_id = v_person AND e.status <> 'unavailable'\n AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = (now() AT TIME ZONE v_tz)::date;\n IF v_entry.id IS NOT NULL AND v_entry.status IN ('available', 'away') THEN\n SELECT count(*) + 1 INTO v_rank FROM public.plg_agenda_turn_entries o\n WHERE o.queue_id = v_queue.id AND o.area_key IS NOT DISTINCT FROM v_entry.area_key\n AND o.status IN ('available', 'away') AND o.id <> v_entry.id\n AND (o.position, o.joined_at, o.id) < (v_entry.position, v_entry.joined_at, v_entry.id);\n END IF;\n RETURN jsonb_build_object(\n 'professionalId', v_person,\n 'selfCheckin', v_queue.self_checkin,\n 'requireLocation', v_queue.geofence_required,\n 'inQueue', v_entry.id IS NOT NULL,\n 'area', coalesce(v_entry.area_key, public.agenda_turn__area_of(v_tenant, v_person)),\n 'status', v_entry.status,\n 'awayReason', v_entry.away_reason,\n 'rank', v_rank);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_my_action(\n p_unit_id uuid,\n p_action text,\n p_latitude double precision DEFAULT NULL,\n p_longitude double precision DEFAULT NULL,\n p_accuracy_m double precision DEFAULT NULL,\n p_reason text DEFAULT NULL\n) RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_person uuid;\n v_queue public.plg_agenda_turn_queues;\n v_entry public.plg_agenda_turn_entries;\n v_reason text := nullif(btrim(coalesce(p_reason, '')), '');\n v_lat numeric;\n v_lng numeric;\n v_distance double precision;\n v_area text;\n v_pos bigint;\nBEGIN\n IF v_tenant IS NULL OR auth.uid() IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF p_action NOT IN ('join', 'leave', 'away', 'back', 'finish') THEN\n RAISE EXCEPTION 'turn_queue_invalid_action:%', p_action USING ERRCODE = '22023';\n END IF;\n SELECT m.person_id INTO v_person FROM app.memberships m\n WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.person_id IS NOT NULL LIMIT 1;\n IF v_person IS NULL THEN RAISE EXCEPTION 'turn_queue_professional_not_linked' USING ERRCODE = '42501'; END IF;\n IF NOT public.agenda_turn__in_unit(v_tenant, p_unit_id, v_person) THEN\n RAISE EXCEPTION 'turn_queue_professional_outside_unit' USING ERRCODE = '42501';\n END IF;\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' FOR UPDATE;\n IF v_queue.id IS NULL OR NOT v_queue.is_active THEN RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '22023'; END IF;\n IF NOT v_queue.self_checkin THEN RAISE EXCEPTION 'turn_queue_checkin_by_frontdesk' USING ERRCODE = '22023'; END IF;\n PERFORM public.agenda_turn__purge_stale(v_queue.id, public.agenda_turn__tz(v_tenant, p_unit_id));\n\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.professional_id = v_person AND e.status <> 'unavailable';\n\n IF p_action = 'join' THEN\n IF v_entry.id IS NOT NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n IF v_queue.geofence_required THEN\n SELECT u.latitude, u.longitude INTO v_lat, v_lng FROM public.v_units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;\n IF v_lat IS NULL OR v_lng IS NULL THEN RAISE EXCEPTION 'unit_has_no_coordinates' USING ERRCODE = '22023'; END IF;\n IF p_latitude IS NULL OR p_longitude IS NULL THEN RAISE EXCEPTION 'location_required' USING ERRCODE = '22023'; END IF;\n v_distance := 6371000 * acos(least(1, greatest(-1,\n cos(radians(v_lat::double precision)) * cos(radians(p_latitude)) * cos(radians(p_longitude) - radians(v_lng::double precision))\n + sin(radians(v_lat::double precision)) * sin(radians(p_latitude)))));\n IF v_distance > v_queue.geofence_radius_meters + least(coalesce(p_accuracy_m, 0), 100) THEN\n RAISE EXCEPTION 'outside_geofence' USING ERRCODE = '42501';\n END IF;\n END IF;\n v_area := public.agenda_turn__area_of(v_tenant, v_person);\n v_pos := public.agenda_turn__next_position(v_queue.id, v_area);\n INSERT INTO public.plg_agenda_turn_entries\n (tenant_id, queue_id, unit_id, professional_id, status, position, area_key, joined_at, available_at, state_changed_at, updated_at)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'available', v_pos, v_area, now(), now(), now(), now())\n ON CONFLICT (queue_id, professional_id) DO UPDATE SET\n status = 'available', position = v_pos, area_key = v_area, joined_at = now(), available_at = now(),\n state_changed_at = now(), appointment_id = NULL, away_reason = NULL, updated_at = now();\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key,\n inside_geofence, location_accuracy_meters, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'joined', 'app', auth.uid(), v_area,\n CASE WHEN v_queue.geofence_required THEN true END, p_accuracy_m, jsonb_build_object('to_position', v_pos));\n RETURN jsonb_build_object('ok', true, 'changed', true, 'area', v_area);\n END IF;\n\n IF v_entry.id IS NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n\n IF p_action = 'leave' THEN\n UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'left', 'app', auth.uid(), v_entry.area_key);\n ELSIF p_action = 'away' THEN\n IF v_entry.status <> 'available' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'away', away_reason = coalesce(v_reason, 'Ausente'),\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'away', 'app', auth.uid(), v_entry.area_key, coalesce(v_reason, 'Ausente'));\n ELSIF p_action = 'back' THEN\n IF v_entry.status <> 'away' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'back', 'app', auth.uid(), v_entry.area_key);\n ELSIF p_action = 'finish' THEN\n IF v_entry.status <> 'serving' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);\n UPDATE public.plg_agenda_turn_entries SET status = 'available', appointment_id = NULL, position = v_pos,\n available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'finished', 'app', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_pos));\n END IF;\n RETURN jsonb_build_object('ok', true, 'changed', true);\nEND $$;\n\n-- 7) Configurar ---------------------------------------------------------------------\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_config(p_unit_id uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues;\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';\n END IF;\n v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);\n RETURN jsonb_build_object(\n 'unit', jsonb_build_object('auto', coalesce(v_queue.follows_appointments, true),\n 'selfCheckin', coalesce(v_queue.self_checkin, true),\n 'requireLocation', coalesce(v_queue.geofence_required, false)),\n 'areas', coalesce((SELECT jsonb_agg(jsonb_build_object('key', d.key, 'name', d.name, 'color', d.color, 'keywords', to_jsonb(d.keywords)) ORDER BY d.sort_order, d.key)\n FROM public.agenda_turn__area_defs(v_tenant) d WHERE d.key <> 'geral'), '[]'::jsonb),\n 'professionals', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'professionalId', p.id, 'name', p.name,\n 'manualArea', pa.area_key,\n 'autoArea', public.agenda_turn__auto_area_of(v_tenant, p.id)\n ) ORDER BY p.name)\n FROM public.people p\n LEFT JOIN public.plg_agenda_turn_professional_areas pa ON pa.tenant_id = v_tenant AND pa.professional_id = p.id\n WHERE p.tenant_id = v_tenant AND p.is_active AND public.agenda_turn__in_unit(v_tenant, p_unit_id, p.id)), '[]'::jsonb),\n 'quickServiceIds', coalesce((SELECT jsonb_agg(q.product_id) FROM public.plg_agenda_turn_quick_services q WHERE q.tenant_id = v_tenant), '[]'::jsonb),\n 'services', coalesce((SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY s.name)\n FROM public.products s\n WHERE s.tenant_id = v_tenant AND s.kind = 'service' AND coalesce(s.is_active, true)), '[]'::jsonb)\n );\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_save_config(p_unit_id uuid, p_config jsonb)\nRETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_area jsonb;\n v_i int := 0;\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.edit', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.edit is required' USING ERRCODE = '42501';\n END IF;\n\n IF jsonb_typeof(p_config->'unit') = 'object' THEN\n INSERT INTO public.plg_agenda_turn_queues AS q (tenant_id, unit_id, is_active, follows_appointments, self_checkin, geofence_required)\n VALUES (v_tenant, p_unit_id, false,\n coalesce((p_config->'unit'->>'auto')::boolean, true),\n coalesce((p_config->'unit'->>'selfCheckin')::boolean, true),\n coalesce((p_config->'unit'->>'requireLocation')::boolean, false))\n ON CONFLICT (tenant_id, unit_id, queue_key) DO UPDATE SET\n follows_appointments = coalesce((p_config->'unit'->>'auto')::boolean, q.follows_appointments),\n self_checkin = coalesce((p_config->'unit'->>'selfCheckin')::boolean, q.self_checkin),\n geofence_required = coalesce((p_config->'unit'->>'requireLocation')::boolean, q.geofence_required),\n updated_at = now();\n END IF;\n\n -- The list sent replaces the network's list; a missing area is deactivated.\n IF jsonb_typeof(p_config->'areas') = 'array' THEN\n UPDATE public.plg_agenda_turn_areas SET is_active = false, updated_at = now()\n WHERE tenant_id = v_tenant AND key NOT IN (SELECT x->>'key' FROM jsonb_array_elements(p_config->'areas') x);\n FOR v_area IN SELECT * FROM jsonb_array_elements(p_config->'areas') LOOP\n v_i := v_i + 1;\n INSERT INTO public.plg_agenda_turn_areas (tenant_id, key, name, color, keywords, sort_order, is_active)\n VALUES (v_tenant, v_area->>'key', btrim(v_area->>'name'), coalesce(v_area->>'color', 'slate'),\n coalesce(ARRAY(SELECT btrim(k) FROM jsonb_array_elements_text(coalesce(v_area->'keywords', '[]'::jsonb)) k WHERE btrim(k) <> ''), ARRAY[]::text[]),\n v_i, true)\n ON CONFLICT (tenant_id, key) DO UPDATE SET\n name = excluded.name, color = excluded.color, keywords = excluded.keywords,\n sort_order = excluded.sort_order, is_active = true, updated_at = now();\n END LOOP;\n END IF;\n\n -- {\"<person uuid>\": \"<area key>\" | null}; null = back to Automático.\n IF jsonb_typeof(p_config->'professionalAreas') = 'object' THEN\n DELETE FROM public.plg_agenda_turn_professional_areas pa\n WHERE pa.tenant_id = v_tenant\n AND pa.professional_id::text IN (SELECT jsonb_object_keys(p_config->'professionalAreas'));\n INSERT INTO public.plg_agenda_turn_professional_areas (tenant_id, professional_id, area_key)\n SELECT v_tenant, p.id, p_config->'professionalAreas'->>p.id::text\n FROM public.people p\n WHERE p.tenant_id = v_tenant\n AND p.id::text IN (SELECT jsonb_object_keys(p_config->'professionalAreas'))\n AND nullif(p_config->'professionalAreas'->>p.id::text, '') IS NOT NULL;\n END IF;\n\n IF jsonb_typeof(p_config->'quickServiceIds') = 'array' THEN\n DELETE FROM public.plg_agenda_turn_quick_services WHERE tenant_id = v_tenant;\n INSERT INTO public.plg_agenda_turn_quick_services (tenant_id, product_id)\n SELECT v_tenant, s.id FROM public.products s\n WHERE s.tenant_id = v_tenant\n AND s.id::text IN (SELECT x #>> '{}' FROM jsonb_array_elements(p_config->'quickServiceIds') x)\n ON CONFLICT DO NOTHING;\n END IF;\n\n RETURN jsonb_build_object('ok', true);\nEND $$;\n\n-- 8) A fila anda sozinha pelo atendimento ---------------------------------------\n-- Replaces 016's trigger body: start leaves the queue unless every service is\n-- quick; leaving in_progress (any status) puts the person back at the END.\n-- Never blocks the agenda.\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_on_appointment_status()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_unit uuid := coalesce(NEW.unit_id, NEW.location_id);\n v_queue public.plg_agenda_turn_queues;\n v_entry public.plg_agenda_turn_entries;\n v_tz text;\n v_pos bigint;\n v_quick boolean;\nBEGIN\n IF NEW.assignee_id IS NULL OR v_unit IS NULL OR NEW.status IS NOT DISTINCT FROM OLD.status THEN RETURN NEW; END IF;\n IF NEW.status <> 'in_progress' AND OLD.status IS DISTINCT FROM 'in_progress' THEN RETURN NEW; END IF;\n\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = NEW.tenant_id AND q.unit_id = v_unit AND q.queue_key = 'walk_in'\n AND q.is_active AND q.follows_appointments\n FOR UPDATE;\n IF v_queue.id IS NULL THEN RETURN NEW; END IF;\n v_tz := public.agenda_turn__tz(NEW.tenant_id, v_unit);\n\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.professional_id = NEW.assignee_id AND e.status <> 'unavailable'\n AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = (now() AT TIME ZONE v_tz)::date;\n IF v_entry.id IS NULL THEN RETURN NEW; END IF;\n\n IF NEW.status = 'in_progress' THEN\n IF v_entry.status = 'serving' THEN RETURN NEW; END IF;\n SELECT bool_and(q.product_id IS NOT NULL) INTO v_quick\n FROM public.appointment_items ai\n LEFT JOIN public.plg_agenda_turn_quick_services q ON q.tenant_id = NEW.tenant_id AND q.product_id = ai.service_id\n WHERE ai.booking_id = NEW.id;\n IF coalesce(v_quick, false) THEN\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key)\n VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'quick_service', 'automatic', NEW.id, v_entry.area_key);\n RETURN NEW;\n END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'serving', away_reason = NULL, appointment_id = NEW.id,\n previous_position = position, state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key)\n VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'started', 'automatic', NEW.id, v_entry.area_key);\n ELSIF v_entry.status = 'serving' AND v_entry.appointment_id = NEW.id THEN\n v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);\n UPDATE public.plg_agenda_turn_entries SET status = 'available', appointment_id = NULL, position = v_pos,\n available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key, payload)\n VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'finished', 'automatic', NEW.id, v_entry.area_key,\n jsonb_build_object('status', NEW.status, 'to_position', v_pos));\n END IF;\n RETURN NEW;\nEXCEPTION WHEN others THEN\n RAISE WARNING 'agenda_turn_queue_on_appointment_status: %', SQLERRM;\n RETURN NEW;\nEND $$;\n\n-- 9) Relatório: a justiça da fila no período --------------------------------------\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_report(p_unit_ids uuid[], p_from date, p_to date)\nRETURNS TABLE(unit_id uuid, unit_name text, professional_id uuid, professional_name text, area text,\n entradas bigint, atendimentos bigint, servicos_rapidos bigint, chamados bigint,\n pulos bigint, ausencias bigint, minutos_ausente numeric)\nLANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n RETURN QUERY\n WITH units_ok AS MATERIALIZED (\n SELECT u.id, u.name FROM public.v_units u\n WHERE u.tenant_id = v_tenant AND (p_unit_ids IS NULL OR u.id = ANY (p_unit_ids))\n AND app.has_unit(u.id) AND app.has_permission('agenda.turn_queue.read', u.id)\n ), ev AS MATERIALIZED (\n SELECT e.*, lead(e.occurred_at) OVER (PARTITION BY e.unit_id, e.professional_id ORDER BY e.occurred_at, e.id) AS proximo_em\n FROM public.plg_agenda_turn_events e JOIN units_ok u ON u.id = e.unit_id\n WHERE e.tenant_id = v_tenant\n AND (e.occurred_at AT TIME ZONE public.agenda_turn__tz(v_tenant, e.unit_id))::date BETWEEN p_from AND p_to\n )\n SELECT ev.unit_id, max(u.name), ev.professional_id, max(p.name), max(ev.area_key),\n count(*) FILTER (WHERE ev.event_type IN ('joined', 'available', 'override_available')),\n count(*) FILTER (WHERE ev.event_type IN ('started', 'advanced_to_service')),\n count(*) FILTER (WHERE ev.event_type = 'quick_service'),\n count(*) FILTER (WHERE ev.event_type = 'called'),\n count(*) FILTER (WHERE ev.event_type = 'skipped'),\n count(*) FILTER (WHERE ev.event_type = 'away'),\n round(coalesce(sum(extract(epoch FROM (ev.proximo_em - ev.occurred_at)) / 60)\n FILTER (WHERE ev.event_type = 'away' AND ev.proximo_em IS NOT NULL), 0)::numeric, 0)\n FROM ev\n JOIN units_ok u ON u.id = ev.unit_id\n JOIN public.people p ON p.id = ev.professional_id\n GROUP BY ev.unit_id, ev.professional_id\n ORDER BY 1, 7 DESC, 4;\nEND $$;\n\n-- 10) Grants e tempo real -----------------------------------------------------------\nREVOKE ALL ON FUNCTION public.agenda_turn__norm(text) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.agenda_turn__area_defs(uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__auto_area_of(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__area_of(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__tz(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__purge_stale(uuid, text) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__renumber(uuid, text) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__next_position(uuid, text) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__in_unit(uuid, uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__queue(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_board(uuid) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_act(uuid, uuid, text, integer, text) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_my_state(uuid) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_my_action(uuid, text, double precision, double precision, double precision, text) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_config(uuid) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_save_config(uuid, jsonb) FROM PUBLIC, anon;\nREVOKE ALL ON FUNCTION public.agenda_turn_queue_report(uuid[], date, date) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_board(uuid) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_act(uuid, uuid, text, integer, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_my_state(uuid) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_my_action(uuid, text, double precision, double precision, double precision, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_config(uuid) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_save_config(uuid, jsonb) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.agenda_turn_queue_report(uuid[], date, date) TO authenticated;\n\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN\n IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'plg_agenda_turn_entries') THEN\n ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_agenda_turn_entries;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'plg_agenda_turn_events') THEN\n ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_agenda_turn_events;\n END IF;\n END IF;\nEND $$;\n`\n\nexport const MIGRATION_022_A_CHAVE_DA_FILA_E_CONFIGURACAO_DA_UNIDADE = `-- A chave da fila é configuração da unidade, não coluna.\n--\n-- 016/020/021 guardaram \"ligada\", \"exigir loja\", raio, \"anda sozinha\" e\n-- \"check-in pelo profissional\" como colunas de plg_agenda_turn_queues. O SDK já\n-- tem casa para isso (127/168): chave registrada em app.setting_keys com\n-- scope 'unit', valor em app.units.settings, lido em cascata\n-- unidade → casa → padrão. Um toggle novo vira uma linha de registro, não uma\n-- migration de coluna, e a rede ganha \"padrão da casa\" sem 18 escritas.\n--\n-- plg_agenda_turn_queues continua: é a FILA (entidade, dona das entradas e da\n-- trilha). Só a configuração sai dela. As colunas antigas ficam paradas e são\n-- removidas numa limpeza à parte (apps publicados ainda leem is_active).\n-- Idempotente: o espelho só copia para unidade que ainda não tem a chave.\n\nSELECT app.seed_setting_keys($$[\n { \"key\": \"agenda.turn_queue.enabled\", \"type\": \"boolean\", \"default\": false, \"scope\": \"unit\", \"reader\": \"plugin:agenda\",\n \"description\": \"Usar a lista da vez nesta unidade (V1: companies.turn_queue_enabled).\" },\n { \"key\": \"agenda.turn_queue.follows_appointments\", \"type\": \"boolean\", \"default\": true, \"scope\": \"unit\", \"reader\": \"plugin:agenda\",\n \"description\": \"A fila anda sozinha pelo atendimento: iniciar tira da vez, concluir devolve ao fim (V1: companies.turn_queue_auto).\" },\n { \"key\": \"agenda.turn_queue.self_checkin\", \"type\": \"boolean\", \"default\": true, \"scope\": \"unit\", \"reader\": \"plugin:agenda\",\n \"description\": \"O profissional faz o próprio check-in pelo botão Cheguei (V1: companies.turn_queue_self_checkin).\" },\n { \"key\": \"agenda.turn_queue.require_location\", \"type\": \"boolean\", \"default\": false, \"scope\": \"unit\", \"reader\": \"plugin:agenda\",\n \"description\": \"O Cheguei só vale dentro do raio da loja (V1: companies.turn_queue_require_location).\" },\n { \"key\": \"agenda.turn_queue.radius_m\", \"type\": \"integer\", \"default\": 80, \"scope\": \"unit\", \"reader\": \"plugin:agenda\",\n \"description\": \"Raio da loja em metros, de 10 a 1000 (V1: companies.turn_queue_radius_m).\" }\n]$$::jsonb);\n\n-- Leitura pela linha da fila: nula quando não há fila, para o chamador decidir.\nCREATE OR REPLACE FUNCTION public.agenda_turn__on(p_queue public.plg_agenda_turn_queues, p_key text)\nRETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT CASE WHEN p_queue.id IS NULL THEN NULL\n ELSE (app.unit_setting_value(p_queue.tenant_id, p_queue.unit_id, 'agenda.turn_queue.' || p_key) #>> '{}')::boolean END;\n$$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn__radius(p_queue public.plg_agenda_turn_queues)\nRETURNS integer LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT coalesce((app.unit_setting_value(p_queue.tenant_id, p_queue.unit_id, 'agenda.turn_queue.radius_m') #>> '{}')::integer, 80);\n$$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn__unit_cfg(p_tenant uuid, p_unit uuid)\nRETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT jsonb_build_object(\n 'enabled', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.enabled') #>> '{}')::boolean, false),\n 'auto', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.follows_appointments') #>> '{}')::boolean, true),\n 'selfCheckin', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.self_checkin') #>> '{}')::boolean, true),\n 'requireLocation', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.require_location') #>> '{}')::boolean, false),\n 'radiusM', coalesce((app.unit_setting_value(p_tenant, p_unit, 'agenda.turn_queue.radius_m') #>> '{}')::integer, 80));\n$$;\n\n-- Grava só a diferença para o padrão da casa; igual ao padrão apaga a exceção.\n-- Compara pelo tenant da unidade, não da sessão: dentro de migration não há sessão.\nCREATE OR REPLACE FUNCTION public.agenda_turn__put(p_unit uuid, p_key text, p_value jsonb)\nRETURNS void LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\n UPDATE app.units u\n SET settings = app.jsonb_set_path(coalesce(u.settings, '{}'::jsonb), string_to_array('agenda.turn_queue.' || p_key, '.'),\n CASE WHEN app.unit_setting_value(u.tenant_id, NULL, 'agenda.turn_queue.' || p_key) IS NOT DISTINCT FROM p_value THEN NULL ELSE p_value END)\n WHERE u.id = p_unit AND p_value IS NOT NULL AND p_value <> 'null'::jsonb;\n$$;\n\n-- Espelho único do que as colunas diziam.\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN\n SELECT q.* FROM public.plg_agenda_turn_queues q JOIN app.units u ON u.id = q.unit_id\n WHERE q.queue_key = 'walk_in' AND u.settings #> '{agenda,turn_queue}' IS NULL\n LOOP\n PERFORM public.agenda_turn__put(r.unit_id, 'enabled', to_jsonb(r.is_active));\n PERFORM public.agenda_turn__put(r.unit_id, 'follows_appointments', to_jsonb(r.follows_appointments));\n PERFORM public.agenda_turn__put(r.unit_id, 'self_checkin', to_jsonb(r.self_checkin));\n PERFORM public.agenda_turn__put(r.unit_id, 'require_location', to_jsonb(r.geofence_required));\n PERFORM public.agenda_turn__put(r.unit_id, 'radius_m', to_jsonb(r.geofence_radius_meters));\n END LOOP;\n -- Re-pass what is already stored so values equal to the house default stop\n -- being exceptions (a first replay wrote them all).\n FOR r IN SELECT u.id, k.key, k.value FROM app.units u, jsonb_each(u.settings #> '{agenda,turn_queue}') k\n WHERE jsonb_typeof(u.settings #> '{agenda,turn_queue}') = 'object' LOOP\n PERFORM public.agenda_turn__put(r.id, r.key, r.value);\n END LOOP;\nEND $$;\n\nCOMMENT ON COLUMN public.plg_agenda_turn_queues.is_active IS 'Superada por app.units.settings agenda.turn_queue.enabled (022). Não ler.';\nCOMMENT ON COLUMN public.plg_agenda_turn_queues.geofence_required IS 'Superada por agenda.turn_queue.require_location (022). Não ler.';\nCOMMENT ON COLUMN public.plg_agenda_turn_queues.geofence_radius_meters IS 'Superada por agenda.turn_queue.radius_m (022). Não ler.';\nCOMMENT ON COLUMN public.plg_agenda_turn_queues.follows_appointments IS 'Superada por agenda.turn_queue.follows_appointments (022). Não ler.';\nCOMMENT ON COLUMN public.plg_agenda_turn_queues.self_checkin IS 'Superada por agenda.turn_queue.self_checkin (022). Não ler.';\n\n-- A aba Lista da vez da unidade (020): mesma assinatura, agora escrevendo a configuração.\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_configure(\n p_unit_id uuid,\n p_enabled boolean,\n p_geofence_required boolean DEFAULT NULL,\n p_radius_meters integer DEFAULT NULL\n)\nRETURNS public.plg_agenda_turn_queues\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_row public.plg_agenda_turn_queues;\n v_cfg jsonb;\nBEGIN\n IF v_tenant IS NULL OR auth.uid() IS NULL THEN\n RAISE EXCEPTION 'agenda_turn_queue_configure: no session or no active tenant' USING ERRCODE = '42501';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant) THEN\n RAISE EXCEPTION 'agenda_turn_queue_configure: unit does not belong to this tenant' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('settings.manage', p_unit_id) THEN\n RAISE EXCEPTION 'agenda_turn_queue_configure: settings.manage is required on this unit' USING ERRCODE = '42501';\n END IF;\n IF p_radius_meters IS NOT NULL AND (p_radius_meters < 10 OR p_radius_meters > 1000) THEN\n RAISE EXCEPTION 'agenda_turn_queue_configure: radius must be between 10 and 1000 meters' USING ERRCODE = '22023';\n END IF;\n\n INSERT INTO public.plg_agenda_turn_queues (tenant_id, unit_id) VALUES (v_tenant, p_unit_id)\n ON CONFLICT (tenant_id, unit_id, queue_key) DO NOTHING;\n PERFORM public.agenda_turn__put(p_unit_id, 'enabled', to_jsonb(p_enabled));\n PERFORM public.agenda_turn__put(p_unit_id, 'require_location', to_jsonb(p_geofence_required));\n PERFORM public.agenda_turn__put(p_unit_id, 'radius_m', to_jsonb(p_radius_meters));\n\n -- The return row carries the EFFECTIVE values, so the caller reads the same\n -- fields it always did.\n SELECT * INTO v_row FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in';\n v_cfg := public.agenda_turn__unit_cfg(v_tenant, p_unit_id);\n v_row.is_active := (v_cfg->>'enabled')::boolean;\n v_row.geofence_required := (v_cfg->>'requireLocation')::boolean;\n v_row.geofence_radius_meters := (v_cfg->>'radiusM')::integer;\n v_row.follows_appointments := (v_cfg->>'auto')::boolean;\n v_row.self_checkin := (v_cfg->>'selfCheckin')::boolean;\n RETURN v_row;\nEND $function$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_config(p_unit_id uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';\n END IF;\n RETURN jsonb_build_object(\n 'unit', public.agenda_turn__unit_cfg(v_tenant, p_unit_id),\n 'areas', coalesce((SELECT jsonb_agg(jsonb_build_object('key', d.key, 'name', d.name, 'color', d.color, 'keywords', to_jsonb(d.keywords)) ORDER BY d.sort_order, d.key)\n FROM public.agenda_turn__area_defs(v_tenant) d WHERE d.key <> 'geral'), '[]'::jsonb),\n 'professionals', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'professionalId', p.id, 'name', p.name,\n 'manualArea', pa.area_key,\n 'autoArea', public.agenda_turn__auto_area_of(v_tenant, p.id)\n ) ORDER BY p.name)\n FROM public.people p\n LEFT JOIN public.plg_agenda_turn_professional_areas pa ON pa.tenant_id = v_tenant AND pa.professional_id = p.id\n WHERE p.tenant_id = v_tenant AND p.is_active AND public.agenda_turn__in_unit(v_tenant, p_unit_id, p.id)), '[]'::jsonb),\n 'quickServiceIds', coalesce((SELECT jsonb_agg(q.product_id) FROM public.plg_agenda_turn_quick_services q WHERE q.tenant_id = v_tenant), '[]'::jsonb),\n 'services', coalesce((SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY s.name)\n FROM public.products s\n WHERE s.tenant_id = v_tenant AND s.kind = 'service' AND coalesce(s.is_active, true)), '[]'::jsonb)\n );\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_save_config(p_unit_id uuid, p_config jsonb)\nRETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_area jsonb;\n v_i int := 0;\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.edit', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.edit is required' USING ERRCODE = '42501';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant) THEN\n RAISE EXCEPTION 'unit does not belong to this tenant' USING ERRCODE = '42501';\n END IF;\n\n IF jsonb_typeof(p_config->'unit') = 'object' THEN\n PERFORM public.agenda_turn__put(p_unit_id, 'follows_appointments', p_config->'unit'->'auto');\n PERFORM public.agenda_turn__put(p_unit_id, 'self_checkin', p_config->'unit'->'selfCheckin');\n PERFORM public.agenda_turn__put(p_unit_id, 'require_location', p_config->'unit'->'requireLocation');\n END IF;\n\n -- The list sent replaces the network's list; a missing area is deactivated.\n IF jsonb_typeof(p_config->'areas') = 'array' THEN\n UPDATE public.plg_agenda_turn_areas SET is_active = false, updated_at = now()\n WHERE tenant_id = v_tenant AND key NOT IN (SELECT x->>'key' FROM jsonb_array_elements(p_config->'areas') x);\n FOR v_area IN SELECT * FROM jsonb_array_elements(p_config->'areas') LOOP\n v_i := v_i + 1;\n INSERT INTO public.plg_agenda_turn_areas (tenant_id, key, name, color, keywords, sort_order, is_active)\n VALUES (v_tenant, v_area->>'key', btrim(v_area->>'name'), coalesce(v_area->>'color', 'slate'),\n coalesce(ARRAY(SELECT btrim(k) FROM jsonb_array_elements_text(coalesce(v_area->'keywords', '[]'::jsonb)) k WHERE btrim(k) <> ''), ARRAY[]::text[]),\n v_i, true)\n ON CONFLICT (tenant_id, key) DO UPDATE SET\n name = excluded.name, color = excluded.color, keywords = excluded.keywords,\n sort_order = excluded.sort_order, is_active = true, updated_at = now();\n END LOOP;\n END IF;\n\n IF jsonb_typeof(p_config->'professionalAreas') = 'object' THEN\n DELETE FROM public.plg_agenda_turn_professional_areas pa\n WHERE pa.tenant_id = v_tenant\n AND pa.professional_id::text IN (SELECT jsonb_object_keys(p_config->'professionalAreas'));\n INSERT INTO public.plg_agenda_turn_professional_areas (tenant_id, professional_id, area_key)\n SELECT v_tenant, p.id, p_config->'professionalAreas'->>p.id::text\n FROM public.people p\n WHERE p.tenant_id = v_tenant\n AND p.id::text IN (SELECT jsonb_object_keys(p_config->'professionalAreas'))\n AND nullif(p_config->'professionalAreas'->>p.id::text, '') IS NOT NULL;\n END IF;\n\n IF jsonb_typeof(p_config->'quickServiceIds') = 'array' THEN\n DELETE FROM public.plg_agenda_turn_quick_services WHERE tenant_id = v_tenant;\n INSERT INTO public.plg_agenda_turn_quick_services (tenant_id, product_id)\n SELECT v_tenant, s.id FROM public.products s\n WHERE s.tenant_id = v_tenant\n AND s.id::text IN (SELECT x #>> '{}' FROM jsonb_array_elements(p_config->'quickServiceIds') x)\n ON CONFLICT DO NOTHING;\n END IF;\n\n RETURN jsonb_build_object('ok', true);\nEND $$;\n\n-- Funções de 016/017/021 com a mesma lógica, lendo a configuração pela cascata.\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_set_availability(\n p_available boolean,\n p_unit_id uuid,\n p_latitude numeric DEFAULT NULL,\n p_longitude numeric DEFAULT NULL,\n p_accuracy_meters numeric DEFAULT NULL,\n p_origin text DEFAULT 'app'\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_actor uuid := auth.uid();\n v_person uuid;\n v_queue public.plg_agenda_turn_queues%ROWTYPE;\n v_lat numeric;\n v_lng numeric;\n v_distance numeric;\n v_position bigint;\n v_entry uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501';\n END IF;\n IF p_unit_id IS NULL THEN\n RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023';\n END IF;\n IF p_origin <> 'app' THEN\n RAISE EXCEPTION 'self_availability_origin_must_be_app' USING ERRCODE = '22023';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.edit', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.edit is required' USING ERRCODE = '42501';\n END IF;\n\n v_person := app.person_for_login(v_tenant, v_actor);\n IF v_person IS NULL THEN\n RAISE EXCEPTION 'sem_ficha_de_profissional' USING ERRCODE = '42501';\n END IF;\n\n INSERT INTO public.plg_agenda_turn_queues (tenant_id, unit_id)\n VALUES (v_tenant, p_unit_id)\n ON CONFLICT (tenant_id, unit_id, queue_key) DO NOTHING;\n\n SELECT * INTO v_queue\n FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id\n AND q.queue_key = 'walk_in' AND public.agenda_turn__on(q, 'enabled')\n FOR UPDATE;\n IF v_queue.id IS NULL THEN\n RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '42501';\n END IF;\n\n IF p_available AND public.agenda_turn__on(v_queue, 'require_location') THEN\n SELECT u.latitude, u.longitude INTO v_lat, v_lng\n FROM public.v_units u\n WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;\n IF v_lat IS NULL OR v_lng IS NULL THEN\n RAISE EXCEPTION 'unit_has_no_coordinates' USING ERRCODE = '22023';\n END IF;\n IF p_latitude IS NULL OR p_longitude IS NULL THEN\n RAISE EXCEPTION 'location_required' USING ERRCODE = '22023';\n END IF;\n v_distance := 6371000 * acos(least(1, greatest(-1,\n cos(radians(v_lat::double precision)) * cos(radians(p_latitude::double precision))\n * cos(radians(p_longitude::double precision) - radians(v_lng::double precision))\n + sin(radians(v_lat::double precision)) * sin(radians(p_latitude::double precision))\n )));\n IF v_distance > public.agenda_turn__radius(v_queue) THEN\n RAISE EXCEPTION 'outside_geofence' USING ERRCODE = '42501';\n END IF;\n END IF;\n\n SELECT coalesce(max(e.position), 0) + 1 INTO v_position\n FROM public.plg_agenda_turn_entries e WHERE e.queue_id = v_queue.id;\n\n INSERT INTO public.plg_agenda_turn_entries\n (tenant_id, queue_id, unit_id, professional_id, status, position, available_at, state_changed_at, appointment_id, updated_at)\n VALUES\n (v_tenant, v_queue.id, p_unit_id, v_person,\n CASE WHEN p_available THEN 'available' ELSE 'unavailable' END,\n CASE WHEN p_available THEN v_position ELSE 0 END,\n CASE WHEN p_available THEN now() ELSE NULL END,\n now(), NULL, now())\n ON CONFLICT (queue_id, professional_id) DO UPDATE SET\n status = EXCLUDED.status,\n position = CASE WHEN p_available THEN v_position ELSE public.plg_agenda_turn_entries.position END,\n available_at = EXCLUDED.available_at,\n state_changed_at = now(),\n appointment_id = NULL,\n updated_at = now()\n RETURNING id INTO v_entry;\n\n INSERT INTO public.plg_agenda_turn_events\n (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id,\n inside_geofence, location_accuracy_meters, payload)\n VALUES\n (v_tenant, v_queue.id, p_unit_id, v_person,\n CASE WHEN p_available THEN 'available' ELSE 'left' END,\n 'app', v_actor, CASE WHEN p_available THEN true ELSE NULL END,\n CASE WHEN p_available THEN p_accuracy_meters ELSE NULL END,\n jsonb_build_object('entry_id', v_entry));\n\n RETURN jsonb_build_object('ok', true, 'status', CASE WHEN p_available THEN 'available' ELSE 'unavailable' END);\nEND $$;\n\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_snapshot(p_unit_id uuid)\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' STABLE AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues%ROWTYPE;\n v_person uuid;\n v_timezone text;\n v_day date;\n v_entries jsonb;\n v_events jsonb;\n v_professionals jsonb;\nBEGIN\n IF p_unit_id IS NULL THEN\n RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';\n END IF;\n\n SELECT coalesce(t.settings->>'timezone', 'America/Sao_Paulo')\n INTO v_timezone\n FROM public.tenants t\n WHERE t.id = v_tenant;\n v_day := (now() AT TIME ZONE v_timezone)::date;\n v_person := app.person_for_login(v_tenant, auth.uid());\n\n SELECT * INTO v_queue\n FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant\n AND q.unit_id = p_unit_id\n AND q.queue_key = 'walk_in'\n AND public.agenda_turn__on(q, 'enabled');\n\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'id', e.id, 'queue_id', e.queue_id, 'unit_id', e.unit_id,\n 'professional_id', e.professional_id, 'professional_name', p.name,\n 'professional_avatar_url', p.avatar_url, 'status', e.status, 'position', e.position,\n 'state_changed_at', e.state_changed_at, 'available_at', e.available_at,\n 'appointment_id', e.appointment_id\n ) ORDER BY CASE e.status WHEN 'available' THEN 0 WHEN 'serving' THEN 1 ELSE 2 END, e.position), '[]'::jsonb)\n INTO v_entries\n FROM public.plg_agenda_turn_entries e\n JOIN public.people p ON p.id = e.professional_id AND p.tenant_id = e.tenant_id\n WHERE e.queue_id = v_queue.id;\n\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'id', ev.id, 'professional_id', ev.professional_id, 'professional_name', p.name,\n 'event_type', ev.event_type, 'origin', ev.origin, 'reason', ev.reason,\n 'occurred_at', ev.occurred_at\n ) ORDER BY ev.occurred_at DESC), '[]'::jsonb)\n INTO v_events\n FROM (\n SELECT * FROM public.plg_agenda_turn_events\n WHERE queue_id = v_queue.id\n AND (occurred_at AT TIME ZONE v_timezone)::date = v_day\n ORDER BY occurred_at DESC LIMIT 50\n ) ev\n JOIN public.people p ON p.id = ev.professional_id AND p.tenant_id = ev.tenant_id;\n\n WITH scheduled AS (\n SELECT DISTINCT s.assignee_id AS professional_id\n FROM public.schedules s\n WHERE s.tenant_id = v_tenant\n AND s.kind = 'working_hours'\n AND s.assignee_id IS NOT NULL\n AND coalesce(s.unit_id, s.location_id) = p_unit_id\n AND (s.valid_from IS NULL OR s.valid_from <= v_day)\n AND (s.valid_to IS NULL OR v_day <= s.valid_to)\n AND (\n (s.specific_date = v_day AND s.is_active)\n OR (\n s.specific_date IS NULL\n AND s.is_active\n AND s.day_of_week = extract(dow FROM v_day)::smallint\n AND public.agenda_week_parity_active(s.valid_from, v_day, s.week_interval)\n AND NOT EXISTS (\n SELECT 1 FROM public.schedules day_override\n WHERE day_override.tenant_id = s.tenant_id\n AND day_override.assignee_id = s.assignee_id\n AND coalesce(day_override.unit_id, day_override.location_id) = p_unit_id\n AND day_override.specific_date = v_day\n )\n )\n )\n ), booked AS (\n SELECT a.assignee_id AS professional_id,\n count(*)::integer AS appointment_count,\n min(a.starts_at) FILTER (WHERE a.starts_at >= now()) AS next_appointment_at\n FROM public.appointments a\n WHERE a.tenant_id = v_tenant\n AND a.assignee_id IS NOT NULL\n AND coalesce(a.unit_id, a.location_id) = p_unit_id\n AND (a.starts_at AT TIME ZONE v_timezone)::date = v_day\n AND coalesce(a.status, '') NOT IN ('cancelled', 'canceled')\n GROUP BY a.assignee_id\n ), candidates AS (\n SELECT professional_id FROM scheduled\n UNION\n SELECT professional_id FROM booked\n UNION\n SELECT e.professional_id FROM public.plg_agenda_turn_entries e WHERE e.queue_id = v_queue.id\n )\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'professional_id', p.id,\n 'professional_name', p.name,\n 'professional_avatar_url', p.avatar_url,\n 'status', coalesce(e.status, 'unavailable'),\n 'position', coalesce(e.position, 0),\n 'last_checkin_at', last_checkin.occurred_at,\n 'is_scheduled', scheduled.professional_id IS NOT NULL,\n 'appointment_count', coalesce(booked.appointment_count, 0),\n 'next_appointment_at', booked.next_appointment_at\n ) ORDER BY\n CASE coalesce(e.status, 'unavailable') WHEN 'available' THEN 0 WHEN 'serving' THEN 1 ELSE 2 END,\n CASE WHEN booked.next_appointment_at IS NOT NULL THEN 0 ELSE 1 END,\n e.position NULLS LAST,\n p.name), '[]'::jsonb)\n INTO v_professionals\n FROM candidates c\n JOIN public.people p ON p.id = c.professional_id AND p.tenant_id = v_tenant AND p.is_active\n LEFT JOIN scheduled ON scheduled.professional_id = p.id\n LEFT JOIN booked ON booked.professional_id = p.id\n LEFT JOIN public.plg_agenda_turn_entries e ON e.queue_id = v_queue.id AND e.professional_id = p.id\n LEFT JOIN LATERAL (\n SELECT ev.occurred_at\n FROM public.plg_agenda_turn_events ev\n WHERE ev.queue_id = v_queue.id\n AND ev.professional_id = p.id\n AND ev.event_type IN ('available', 'override_available')\n AND (ev.occurred_at AT TIME ZONE v_timezone)::date = v_day\n ORDER BY ev.occurred_at DESC\n LIMIT 1\n ) last_checkin ON true;\n\n RETURN jsonb_build_object(\n 'queue_id', v_queue.id,\n 'unit_id', p_unit_id,\n 'queue_name', coalesce(v_queue.name, 'Próximo da vez'),\n 'current_professional_id', v_person,\n 'date', v_day,\n 'professionals', v_professionals,\n 'entries', v_entries,\n 'events', v_events\n );\nEND $$;\n\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_override(\n p_professional_id uuid,\n p_unit_id uuid,\n p_available boolean,\n p_reason text\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues%ROWTYPE;\n v_position bigint;\nBEGIN\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.manage', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.manage is required' USING ERRCODE = '42501';\n END IF;\n IF nullif(trim(p_reason), '') IS NULL THEN\n RAISE EXCEPTION 'override_reason_required' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' AND public.agenda_turn__on(q, 'enabled')\n FOR UPDATE;\n IF v_queue.id IS NULL THEN RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '42501'; END IF;\n SELECT coalesce(max(position), 0) + 1 INTO v_position FROM public.plg_agenda_turn_entries WHERE queue_id = v_queue.id;\n INSERT INTO public.plg_agenda_turn_entries\n (tenant_id, queue_id, unit_id, professional_id, status, position, available_at, state_changed_at, updated_at)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id,\n CASE WHEN p_available THEN 'available' ELSE 'unavailable' END,\n CASE WHEN p_available THEN v_position ELSE 0 END,\n CASE WHEN p_available THEN now() ELSE NULL END, now(), now())\n ON CONFLICT (queue_id, professional_id) DO UPDATE SET\n status = EXCLUDED.status, position = CASE WHEN p_available THEN v_position ELSE public.plg_agenda_turn_entries.position END,\n available_at = EXCLUDED.available_at, state_changed_at = now(), appointment_id = NULL, updated_at = now();\n INSERT INTO public.plg_agenda_turn_events\n (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, reason)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id,\n CASE WHEN p_available THEN 'override_available' ELSE 'override_left' END,\n 'frontdesk', auth.uid(), trim(p_reason));\n RETURN jsonb_build_object('ok', true);\nEND $$;\n\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_on_appointment_status()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_unit uuid := coalesce(NEW.unit_id, NEW.location_id);\n v_queue public.plg_agenda_turn_queues;\n v_entry public.plg_agenda_turn_entries;\n v_tz text;\n v_pos bigint;\n v_quick boolean;\nBEGIN\n IF NEW.assignee_id IS NULL OR v_unit IS NULL OR NEW.status IS NOT DISTINCT FROM OLD.status THEN RETURN NEW; END IF;\n IF NEW.status <> 'in_progress' AND OLD.status IS DISTINCT FROM 'in_progress' THEN RETURN NEW; END IF;\n\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = NEW.tenant_id AND q.unit_id = v_unit AND q.queue_key = 'walk_in'\n AND public.agenda_turn__on(q, 'enabled') AND public.agenda_turn__on(q, 'follows_appointments')\n FOR UPDATE;\n IF v_queue.id IS NULL THEN RETURN NEW; END IF;\n v_tz := public.agenda_turn__tz(NEW.tenant_id, v_unit);\n\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.professional_id = NEW.assignee_id AND e.status <> 'unavailable'\n AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = (now() AT TIME ZONE v_tz)::date;\n IF v_entry.id IS NULL THEN RETURN NEW; END IF;\n\n IF NEW.status = 'in_progress' THEN\n IF v_entry.status = 'serving' THEN RETURN NEW; END IF;\n SELECT bool_and(q.product_id IS NOT NULL) INTO v_quick\n FROM public.appointment_items ai\n LEFT JOIN public.plg_agenda_turn_quick_services q ON q.tenant_id = NEW.tenant_id AND q.product_id = ai.service_id\n WHERE ai.booking_id = NEW.id;\n IF coalesce(v_quick, false) THEN\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key)\n VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'quick_service', 'automatic', NEW.id, v_entry.area_key);\n RETURN NEW;\n END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'serving', away_reason = NULL, appointment_id = NEW.id,\n previous_position = position, state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key)\n VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'started', 'automatic', NEW.id, v_entry.area_key);\n ELSIF v_entry.status = 'serving' AND v_entry.appointment_id = NEW.id THEN\n v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);\n UPDATE public.plg_agenda_turn_entries SET status = 'available', appointment_id = NULL, position = v_pos,\n available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, appointment_id, area_key, payload)\n VALUES (NEW.tenant_id, v_queue.id, v_unit, NEW.assignee_id, 'finished', 'automatic', NEW.id, v_entry.area_key,\n jsonb_build_object('status', NEW.status, 'to_position', v_pos));\n END IF;\n RETURN NEW;\nEXCEPTION WHEN others THEN\n RAISE WARNING 'agenda_turn_queue_on_appointment_status: %', SQLERRM;\n RETURN NEW;\nEND $$;\n\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_board(p_unit_id uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues;\n v_tz text;\n v_day date;\n v_lat numeric;\n v_lng numeric;\n v_result jsonb;\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF p_unit_id IS NULL THEN RAISE EXCEPTION 'unit_required' USING ERRCODE = '22023'; END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.read', p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.read is required' USING ERRCODE = '42501';\n END IF;\n v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);\n v_tz := public.agenda_turn__tz(v_tenant, p_unit_id);\n v_day := (now() AT TIME ZONE v_tz)::date;\n SELECT u.latitude, u.longitude INTO v_lat, v_lng FROM public.v_units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;\n\n WITH ent AS (\n SELECT e.*, row_number() OVER (PARTITION BY e.area_key, (e.status IN ('available', 'away'))\n ORDER BY e.position, e.joined_at, e.id) AS rnk\n FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.status IN ('available', 'away', 'serving')\n AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = v_day\n ), appts AS (\n SELECT a.assignee_id AS professional_id, count(*)::int AS n,\n min(a.starts_at) FILTER (WHERE a.starts_at >= now() AND a.status NOT IN ('completed', 'cancelled', 'no_show')) AS proximo\n FROM public.appointments a\n WHERE a.tenant_id = v_tenant AND a.assignee_id IS NOT NULL\n AND coalesce(a.unit_id, a.location_id) = p_unit_id\n AND (a.starts_at AT TIME ZONE v_tz)::date = v_day\n AND a.status <> 'cancelled'\n GROUP BY 1\n ), roster AS (\n SELECT p.id, p.name, p.avatar_url, public.agenda_turn__area_of(v_tenant, p.id) AS area\n FROM public.people p\n WHERE p.tenant_id = v_tenant AND p.is_active\n AND (public.agenda_turn__in_unit(v_tenant, p_unit_id, p.id)\n OR EXISTS (SELECT 1 FROM appts t WHERE t.professional_id = p.id))\n ), served AS (\n SELECT ev.professional_id,\n count(*) FILTER (WHERE ev.event_type IN ('started', 'advanced_to_service'))::int AS atendimentos\n FROM public.plg_agenda_turn_events ev\n WHERE ev.queue_id = v_queue.id AND (ev.occurred_at AT TIME ZONE v_tz)::date = v_day\n GROUP BY 1\n )\n SELECT jsonb_build_object(\n 'unitId', p_unit_id,\n 'today', v_day,\n 'enabled', coalesce(public.agenda_turn__on(v_queue, 'enabled'), false),\n 'selfCheckin', coalesce(public.agenda_turn__on(v_queue, 'self_checkin'), true),\n 'auto', coalesce(public.agenda_turn__on(v_queue, 'follows_appointments'), true),\n 'requireLocation', coalesce(public.agenda_turn__on(v_queue, 'require_location'), false),\n 'locationConfigured', v_lat IS NOT NULL AND v_lng IS NOT NULL,\n 'areas', coalesce((SELECT jsonb_agg(jsonb_build_object('key', d.key, 'name', d.name, 'color', d.color) ORDER BY d.sort_order, d.key)\n FROM public.agenda_turn__area_defs(v_tenant) d), '[]'::jsonb),\n 'entries', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'professionalId', e.professional_id, 'name', p.name, 'avatarUrl', p.avatar_url,\n 'area', coalesce(e.area_key, 'geral'), 'status', e.status,\n 'rank', CASE WHEN e.status IN ('available', 'away') THEN e.rnk END,\n 'awayReason', e.away_reason, 'since', e.state_changed_at, 'joinedAt', e.joined_at,\n 'appointmentId', e.appointment_id,\n 'servedToday', coalesce(s.atendimentos, 0)\n ) ORDER BY e.area_key, (e.status = 'serving'), e.rnk)\n FROM ent e JOIN public.people p ON p.id = e.professional_id\n LEFT JOIN served s ON s.professional_id = e.professional_id), '[]'::jsonb),\n 'roster', coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'professionalId', r.id, 'name', r.name, 'avatarUrl', r.avatar_url, 'area', r.area,\n 'appointmentsToday', coalesce(t.n, 0), 'nextAppointmentAt', t.proximo,\n 'servedToday', coalesce(s.atendimentos, 0)) ORDER BY r.name)\n FROM roster r\n LEFT JOIN appts t ON t.professional_id = r.id\n LEFT JOIN served s ON s.professional_id = r.id\n WHERE NOT EXISTS (SELECT 1 FROM ent e WHERE e.professional_id = r.id)), '[]'::jsonb),\n 'events', coalesce((\n SELECT jsonb_agg(x.item ORDER BY x.at DESC) FROM (\n SELECT jsonb_build_object('id', ev.id, 'professionalId', ev.professional_id, 'name', ep.name,\n 'type', ev.event_type, 'origin', ev.origin, 'area', ev.area_key,\n 'reason', coalesce(ev.reason, ev.payload->>'reason'), 'createdAt', ev.occurred_at) AS item,\n ev.occurred_at AS at\n FROM public.plg_agenda_turn_events ev\n JOIN public.people ep ON ep.id = ev.professional_id\n WHERE ev.queue_id = v_queue.id AND (ev.occurred_at AT TIME ZONE v_tz)::date = v_day\n ORDER BY ev.occurred_at DESC LIMIT 60) x), '[]'::jsonb)\n ) INTO v_result;\n RETURN v_result;\nEND $$;\n\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_act(\n p_unit_id uuid,\n p_professional_id uuid,\n p_action text,\n p_position integer DEFAULT NULL,\n p_reason text DEFAULT NULL\n) RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_queue public.plg_agenda_turn_queues;\n v_entry public.plg_agenda_turn_entries;\n v_need text := CASE p_action WHEN 'join' THEN 'create' WHEN 'leave' THEN 'delete' ELSE 'edit' END;\n v_reason text := nullif(btrim(coalesce(p_reason, '')), '');\n v_area text;\n v_pos bigint;\n v_ids uuid[];\n v_users uuid[];\nBEGIN\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF p_action NOT IN ('join', 'leave', 'away', 'back', 'move', 'start', 'finish', 'skip', 'call') THEN\n RAISE EXCEPTION 'turn_queue_invalid_action:%', p_action USING ERRCODE = '22023';\n END IF;\n IF NOT app.has_unit(p_unit_id) OR NOT app.has_permission('agenda.turn_queue.' || v_need, p_unit_id) THEN\n RAISE EXCEPTION 'agenda.turn_queue.% is required', v_need USING ERRCODE = '42501';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = p_professional_id AND p.tenant_id = v_tenant AND p.is_active) THEN\n RAISE EXCEPTION 'turn_queue_invalid_professional' USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' FOR UPDATE;\n IF v_queue.id IS NULL OR NOT public.agenda_turn__on(v_queue, 'enabled') THEN\n RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '22023';\n END IF;\n PERFORM public.agenda_turn__purge_stale(v_queue.id, public.agenda_turn__tz(v_tenant, p_unit_id));\n\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.professional_id = p_professional_id;\n IF v_entry.id IS NOT NULL AND v_entry.status = 'unavailable' THEN v_entry := NULL; END IF;\n\n IF p_action = 'join' THEN\n IF v_entry.id IS NOT NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n v_area := public.agenda_turn__area_of(v_tenant, p_professional_id);\n v_pos := public.agenda_turn__next_position(v_queue.id, v_area);\n INSERT INTO public.plg_agenda_turn_entries\n (tenant_id, queue_id, unit_id, professional_id, status, position, area_key, joined_at, available_at, state_changed_at, appointment_id, away_reason, updated_at)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'available', v_pos, v_area, now(), now(), now(), NULL, NULL, now())\n ON CONFLICT (queue_id, professional_id) DO UPDATE SET\n status = 'available', position = v_pos, area_key = v_area, joined_at = now(), available_at = now(),\n state_changed_at = now(), appointment_id = NULL, away_reason = NULL, updated_at = now();\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'joined', 'frontdesk', auth.uid(), v_area, jsonb_build_object('to_position', v_pos));\n RETURN jsonb_build_object('ok', true, 'changed', true, 'area', v_area, 'position', v_pos);\n END IF;\n\n IF p_action = 'call' THEN\n SELECT array_agg(DISTINCT m.user_id) INTO v_users FROM app.memberships m\n WHERE m.tenant_id = v_tenant AND m.person_id = p_professional_id AND m.active;\n IF v_users IS NOT NULL THEN\n INSERT INTO public.notifications (tenant_id, user_id, title, body, type, link, subject_type, subject_id)\n SELECT v_tenant, u, 'Cliente para você na recepção', coalesce(v_reason, 'Você é o próximo da vez.'),\n 'turn_queue_call', '#/agenda', 'person', p_professional_id\n FROM unnest(v_users) u;\n END IF;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'called', 'frontdesk', auth.uid(),\n coalesce(v_entry.area_key, public.agenda_turn__area_of(v_tenant, p_professional_id)), v_reason,\n jsonb_build_object('notified', v_users IS NOT NULL));\n RETURN jsonb_build_object('ok', true, 'changed', true, 'notified', v_users IS NOT NULL);\n END IF;\n\n IF v_entry.id IS NULL THEN RAISE EXCEPTION 'turn_queue_not_in_queue' USING ERRCODE = '22023'; END IF;\n\n IF p_action = 'leave' THEN\n UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'left', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('from_position', v_entry.position));\n\n ELSIF p_action = 'away' THEN\n IF v_entry.status = 'serving' THEN RAISE EXCEPTION 'turn_queue_in_service' USING ERRCODE = '22023'; END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'away', away_reason = coalesce(v_reason, 'Ausente'),\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'away', 'frontdesk', auth.uid(), v_entry.area_key, coalesce(v_reason, 'Ausente'),\n jsonb_build_object('from_position', v_entry.position));\n\n ELSIF p_action = 'back' THEN\n IF v_entry.status <> 'away' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'back', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_entry.position));\n\n ELSIF p_action = 'move' THEN\n IF v_entry.status = 'serving' THEN RAISE EXCEPTION 'turn_queue_in_service' USING ERRCODE = '22023'; END IF;\n IF p_position IS NULL OR p_position < 1 THEN RAISE EXCEPTION 'turn_queue_invalid_position' USING ERRCODE = '22023'; END IF;\n SELECT array_agg(id ORDER BY position, joined_at, id) INTO v_ids FROM public.plg_agenda_turn_entries\n WHERE queue_id = v_queue.id AND area_key IS NOT DISTINCT FROM v_entry.area_key\n AND status IN ('available', 'away') AND id <> v_entry.id;\n v_ids := coalesce(v_ids, ARRAY[]::uuid[]);\n v_pos := least(p_position, coalesce(array_length(v_ids, 1), 0) + 1);\n v_ids := v_ids[1:v_pos - 1] || v_entry.id || v_ids[v_pos:];\n UPDATE public.plg_agenda_turn_entries e SET position = o.ord, updated_at = now()\n FROM unnest(v_ids) WITH ORDINALITY o(id, ord) WHERE e.id = o.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'moved', 'frontdesk', auth.uid(), v_entry.area_key,\n jsonb_build_object('from_position', v_entry.position, 'to_position', v_pos));\n\n ELSIF p_action = 'start' THEN\n IF v_entry.status = 'serving' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'serving', away_reason = NULL, previous_position = position,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'started', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('from_position', v_entry.position));\n\n ELSIF p_action = 'finish' THEN\n v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);\n UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL, appointment_id = NULL,\n position = v_pos, available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'finished', 'frontdesk', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_pos));\n\n ELSIF p_action = 'skip' THEN\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, p_professional_id, 'skipped', 'frontdesk', auth.uid(), v_entry.area_key, v_reason,\n jsonb_build_object('from_position', v_entry.position));\n END IF;\n\n RETURN jsonb_build_object('ok', true, 'changed', true);\nEND $$;\n\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_my_state(p_unit_id uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_person uuid;\n v_queue public.plg_agenda_turn_queues;\n v_entry public.plg_agenda_turn_entries;\n v_tz text;\n v_rank bigint;\nBEGIN\n IF v_tenant IS NULL OR auth.uid() IS NULL OR p_unit_id IS NULL THEN RETURN NULL; END IF;\n SELECT m.person_id INTO v_person FROM app.memberships m\n WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.person_id IS NOT NULL LIMIT 1;\n IF v_person IS NULL OR NOT public.agenda_turn__in_unit(v_tenant, p_unit_id, v_person) THEN RETURN NULL; END IF;\n v_queue := public.agenda_turn__queue(v_tenant, p_unit_id);\n IF v_queue.id IS NULL OR NOT public.agenda_turn__on(v_queue, 'enabled') THEN RETURN NULL; END IF;\n v_tz := public.agenda_turn__tz(v_tenant, p_unit_id);\n\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.professional_id = v_person AND e.status <> 'unavailable'\n AND (coalesce(e.joined_at, e.available_at, e.state_changed_at) AT TIME ZONE v_tz)::date = (now() AT TIME ZONE v_tz)::date;\n IF v_entry.id IS NOT NULL AND v_entry.status IN ('available', 'away') THEN\n SELECT count(*) + 1 INTO v_rank FROM public.plg_agenda_turn_entries o\n WHERE o.queue_id = v_queue.id AND o.area_key IS NOT DISTINCT FROM v_entry.area_key\n AND o.status IN ('available', 'away') AND o.id <> v_entry.id\n AND (o.position, o.joined_at, o.id) < (v_entry.position, v_entry.joined_at, v_entry.id);\n END IF;\n RETURN jsonb_build_object(\n 'professionalId', v_person,\n 'selfCheckin', public.agenda_turn__on(v_queue, 'self_checkin'),\n 'requireLocation', public.agenda_turn__on(v_queue, 'require_location'),\n 'inQueue', v_entry.id IS NOT NULL,\n 'area', coalesce(v_entry.area_key, public.agenda_turn__area_of(v_tenant, v_person)),\n 'status', v_entry.status,\n 'awayReason', v_entry.away_reason,\n 'rank', v_rank);\nEND $$;\n\n\nCREATE OR REPLACE FUNCTION public.agenda_turn_queue_my_action(\n p_unit_id uuid,\n p_action text,\n p_latitude double precision DEFAULT NULL,\n p_longitude double precision DEFAULT NULL,\n p_accuracy_m double precision DEFAULT NULL,\n p_reason text DEFAULT NULL\n) RETURNS jsonb LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_person uuid;\n v_queue public.plg_agenda_turn_queues;\n v_entry public.plg_agenda_turn_entries;\n v_reason text := nullif(btrim(coalesce(p_reason, '')), '');\n v_lat numeric;\n v_lng numeric;\n v_distance double precision;\n v_area text;\n v_pos bigint;\nBEGIN\n IF v_tenant IS NULL OR auth.uid() IS NULL THEN RAISE EXCEPTION 'no_active_tenant' USING ERRCODE = '42501'; END IF;\n IF p_action NOT IN ('join', 'leave', 'away', 'back', 'finish') THEN\n RAISE EXCEPTION 'turn_queue_invalid_action:%', p_action USING ERRCODE = '22023';\n END IF;\n SELECT m.person_id INTO v_person FROM app.memberships m\n WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.person_id IS NOT NULL LIMIT 1;\n IF v_person IS NULL THEN RAISE EXCEPTION 'turn_queue_professional_not_linked' USING ERRCODE = '42501'; END IF;\n IF NOT public.agenda_turn__in_unit(v_tenant, p_unit_id, v_person) THEN\n RAISE EXCEPTION 'turn_queue_professional_outside_unit' USING ERRCODE = '42501';\n END IF;\n SELECT * INTO v_queue FROM public.plg_agenda_turn_queues q\n WHERE q.tenant_id = v_tenant AND q.unit_id = p_unit_id AND q.queue_key = 'walk_in' FOR UPDATE;\n IF v_queue.id IS NULL OR NOT public.agenda_turn__on(v_queue, 'enabled') THEN RAISE EXCEPTION 'turn_queue_disabled' USING ERRCODE = '22023'; END IF;\n IF NOT public.agenda_turn__on(v_queue, 'self_checkin') THEN RAISE EXCEPTION 'turn_queue_checkin_by_frontdesk' USING ERRCODE = '22023'; END IF;\n PERFORM public.agenda_turn__purge_stale(v_queue.id, public.agenda_turn__tz(v_tenant, p_unit_id));\n\n SELECT * INTO v_entry FROM public.plg_agenda_turn_entries e\n WHERE e.queue_id = v_queue.id AND e.professional_id = v_person AND e.status <> 'unavailable';\n\n IF p_action = 'join' THEN\n IF v_entry.id IS NOT NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n IF public.agenda_turn__on(v_queue, 'require_location') THEN\n SELECT u.latitude, u.longitude INTO v_lat, v_lng FROM public.v_units u WHERE u.id = p_unit_id AND u.tenant_id = v_tenant;\n IF v_lat IS NULL OR v_lng IS NULL THEN RAISE EXCEPTION 'unit_has_no_coordinates' USING ERRCODE = '22023'; END IF;\n IF p_latitude IS NULL OR p_longitude IS NULL THEN RAISE EXCEPTION 'location_required' USING ERRCODE = '22023'; END IF;\n v_distance := 6371000 * acos(least(1, greatest(-1,\n cos(radians(v_lat::double precision)) * cos(radians(p_latitude)) * cos(radians(p_longitude) - radians(v_lng::double precision))\n + sin(radians(v_lat::double precision)) * sin(radians(p_latitude)))));\n IF v_distance > public.agenda_turn__radius(v_queue) + least(coalesce(p_accuracy_m, 0), 100) THEN\n RAISE EXCEPTION 'outside_geofence' USING ERRCODE = '42501';\n END IF;\n END IF;\n v_area := public.agenda_turn__area_of(v_tenant, v_person);\n v_pos := public.agenda_turn__next_position(v_queue.id, v_area);\n INSERT INTO public.plg_agenda_turn_entries\n (tenant_id, queue_id, unit_id, professional_id, status, position, area_key, joined_at, available_at, state_changed_at, updated_at)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'available', v_pos, v_area, now(), now(), now(), now())\n ON CONFLICT (queue_id, professional_id) DO UPDATE SET\n status = 'available', position = v_pos, area_key = v_area, joined_at = now(), available_at = now(),\n state_changed_at = now(), appointment_id = NULL, away_reason = NULL, updated_at = now();\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key,\n inside_geofence, location_accuracy_meters, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'joined', 'app', auth.uid(), v_area,\n CASE WHEN public.agenda_turn__on(v_queue, 'require_location') THEN true END, p_accuracy_m, jsonb_build_object('to_position', v_pos));\n RETURN jsonb_build_object('ok', true, 'changed', true, 'area', v_area);\n END IF;\n\n IF v_entry.id IS NULL THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n\n IF p_action = 'leave' THEN\n UPDATE public.plg_agenda_turn_entries SET status = 'unavailable', away_reason = NULL, appointment_id = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n PERFORM public.agenda_turn__renumber(v_queue.id, v_entry.area_key);\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'left', 'app', auth.uid(), v_entry.area_key);\n ELSIF p_action = 'away' THEN\n IF v_entry.status <> 'available' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'away', away_reason = coalesce(v_reason, 'Ausente'),\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, reason)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'away', 'app', auth.uid(), v_entry.area_key, coalesce(v_reason, 'Ausente'));\n ELSIF p_action = 'back' THEN\n IF v_entry.status <> 'away' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n UPDATE public.plg_agenda_turn_entries SET status = 'available', away_reason = NULL,\n state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'back', 'app', auth.uid(), v_entry.area_key);\n ELSIF p_action = 'finish' THEN\n IF v_entry.status <> 'serving' THEN RETURN jsonb_build_object('ok', true, 'changed', false); END IF;\n v_pos := public.agenda_turn__next_position(v_queue.id, v_entry.area_key);\n UPDATE public.plg_agenda_turn_entries SET status = 'available', appointment_id = NULL, position = v_pos,\n available_at = now(), state_changed_at = now(), updated_at = now() WHERE id = v_entry.id;\n INSERT INTO public.plg_agenda_turn_events (tenant_id, queue_id, unit_id, professional_id, event_type, origin, actor_user_id, area_key, payload)\n VALUES (v_tenant, v_queue.id, p_unit_id, v_person, 'finished', 'app', auth.uid(), v_entry.area_key, jsonb_build_object('to_position', v_pos));\n END IF;\n RETURN jsonb_build_object('ok', true, 'changed', true);\nEND $$;\n\n\nREVOKE ALL ON FUNCTION public.agenda_turn__on(public.plg_agenda_turn_queues, text) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__radius(public.plg_agenda_turn_queues) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__unit_cfg(uuid, uuid) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.agenda_turn__put(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\n`\n\nexport const MIGRATIONS: Array<{ id: string; sql: string }> = [\n { id: \"000_baseline\", sql: MIGRATION_000_BASELINE },\n { id: \"001_the_connector_definers_keep_a_pinned_path\", sql: MIGRATION_001_THE_CONNECTOR_DEFINERS_KEEP_A_PINNED_PATH },\n { id: \"002_the_appointment_names_the_unit\", sql: MIGRATION_002_THE_APPOINTMENT_NAMES_THE_UNIT },\n { id: \"003_the_client_arrives_before_being_served\", sql: MIGRATION_003_THE_CLIENT_ARRIVES_BEFORE_BEING_SERVED },\n { id: \"004_the_appointment_carries_its_unit\", sql: MIGRATION_004_THE_APPOINTMENT_CARRIES_ITS_UNIT },\n { id: \"005_the_queue_knows_how_long_she_waited\", sql: MIGRATION_005_THE_QUEUE_KNOWS_HOW_LONG_SHE_WAITED },\n { id: \"006_the_agenda_stops_pricing_every_invoice\", sql: MIGRATION_006_THE_AGENDA_STOPS_PRICING_EVERY_INVOICE },\n { id: \"007_o_cascade_levou_a_agenda_junto\", sql: MIGRATION_007_O_CASCADE_LEVOU_A_AGENDA_JUNTO },\n { id: \"008_a_agenda_do_v1_atravessa\", sql: MIGRATION_008_A_AGENDA_DO_V1_ATRAVESSA },\n { id: \"009_o_executor_se_acha_pelo_pedido\", sql: MIGRATION_009_O_EXECUTOR_SE_ACHA_PELO_PEDIDO },\n { id: \"010_a_grade_do_v1_atravessa\", sql: MIGRATION_010_A_GRADE_DO_V1_ATRAVESSA },\n { id: \"011_a_sala_entra_no_compromisso\", sql: MIGRATION_011_A_SALA_ENTRA_NO_COMPROMISSO },\n { id: \"012_a_sala_sem_nome_tambem_e_uma_sala\", sql: MIGRATION_012_A_SALA_SEM_NOME_TAMBEM_E_UMA_SALA },\n { id: \"013_o_selo_do_atendimento_tem_onde_morar\", sql: MIGRATION_013_O_SELO_DO_ATENDIMENTO_TEM_ONDE_MORAR },\n { id: \"014_a_configuracao_nao_e_uma_tabela\", sql: MIGRATION_014_A_CONFIGURACAO_NAO_E_UMA_TABELA },\n { id: \"015_o_feriado_tambem_tem_observacao_e_etiqueta\", sql: MIGRATION_015_O_FERIADO_TAMBEM_TEM_OBSERVACAO_E_ETIQUETA },\n { id: \"016_a_vez_e_um_fato\", sql: MIGRATION_016_A_VEZ_E_UM_FATO },\n { id: \"017_a_equipe_do_dia_aparece\", sql: MIGRATION_017_A_EQUIPE_DO_DIA_APARECE },\n { id: \"018_o_agendamento_que_se_repete\", sql: MIGRATION_018_O_AGENDAMENTO_QUE_SE_REPETE },\n { id: \"019_a_serie_nasce_numa_viagem_so\", sql: MIGRATION_019_A_SERIE_NASCE_NUMA_VIAGEM_SO },\n { id: \"020_a_lista_da_vez_se_liga_no_cadastro_da_unidade\", sql: MIGRATION_020_A_LISTA_DA_VEZ_SE_LIGA_NO_CADASTRO_DA_UNIDADE },\n { id: \"021_a_vez_por_area\", sql: MIGRATION_021_A_VEZ_POR_AREA },\n { id: \"022_a_chave_da_fila_e_configuracao_da_unidade\", sql: MIGRATION_022_A_CHAVE_DA_FILA_E_CONFIGURACAO_DA_UNIDADE },\n]\n"]}