@maudecode/kanvil 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/apps/server/.env.example +19 -0
  2. package/apps/server/dist/auth-Cuo7ECLh.mjs +7770 -0
  3. package/apps/server/dist/client.mjs +884 -0
  4. package/apps/server/dist/{config-CbNqtJEj.mjs → config-DtNfVSnB.mjs} +28 -133
  5. package/apps/server/dist/index.mjs +2503 -295
  6. package/apps/server/dist/instance-CCeXRJ6m.mjs +133 -0
  7. package/apps/server/dist/migrate.mjs +1 -1
  8. package/apps/server/dist/recover.mjs +5 -4
  9. package/apps/server/dist/src-tQ82UI8k.mjs +4818 -0
  10. package/apps/server/drizzle/0014_inbox_dismissal.sql +1 -0
  11. package/apps/server/drizzle/0015_notification_categories.sql +14 -0
  12. package/apps/server/drizzle/0016_ticket_resources.sql +19 -0
  13. package/apps/server/drizzle/0017_project_backgrounds.sql +3 -0
  14. package/apps/server/drizzle/0018_delivery_outbox.sql +65 -0
  15. package/apps/server/drizzle/0019_retire_push_deliveries.sql +1 -0
  16. package/apps/server/drizzle/0020_calendar_feeds.sql +16 -0
  17. package/apps/server/drizzle/0021_staged_uploads.sql +14 -0
  18. package/apps/server/drizzle/0022_manual_order.sql +6 -0
  19. package/apps/server/drizzle/0023_workspace_invitations.sql +22 -0
  20. package/apps/server/drizzle/0024_workflow_states.sql +39 -0
  21. package/apps/server/drizzle/0025_workspace_labels.sql +10 -0
  22. package/apps/server/drizzle/0026_public_read.sql +1 -0
  23. package/apps/server/drizzle/0027_custom_fields.sql +16 -0
  24. package/apps/server/drizzle/0028_project_lifecycle.sql +11 -0
  25. package/apps/server/drizzle/0029_ticket_relocation.sql +16 -0
  26. package/apps/server/drizzle/0030_repository_connections.sql +39 -0
  27. package/apps/server/drizzle/0031_repository_providers.sql +3 -0
  28. package/apps/server/drizzle/0032_workspace_ownership.sql +11 -0
  29. package/apps/server/drizzle/0033_import_runs.sql +38 -0
  30. package/apps/server/drizzle/0034_issue_sync.sql +38 -0
  31. package/apps/server/drizzle/0035_account_deletion.sql +9 -0
  32. package/apps/server/drizzle/0036_workspace_roles.sql +16 -0
  33. package/apps/server/drizzle/0037_email_code_owner.sql +14 -0
  34. package/apps/server/drizzle/0038_time_entries.sql +19 -0
  35. package/apps/server/drizzle/meta/0014_snapshot.json +4246 -0
  36. package/apps/server/drizzle/meta/0015_snapshot.json +4297 -0
  37. package/apps/server/drizzle/meta/0016_snapshot.json +4443 -0
  38. package/apps/server/drizzle/meta/0017_snapshot.json +4468 -0
  39. package/apps/server/drizzle/meta/0018_snapshot.json +4948 -0
  40. package/apps/server/drizzle/meta/0019_snapshot.json +4826 -0
  41. package/apps/server/drizzle/meta/0020_snapshot.json +4947 -0
  42. package/apps/server/drizzle/meta/0021_snapshot.json +5059 -0
  43. package/apps/server/drizzle/meta/0022_snapshot.json +5094 -0
  44. package/apps/server/drizzle/meta/0023_snapshot.json +5267 -0
  45. package/apps/server/drizzle/meta/0024_snapshot.json +5405 -0
  46. package/apps/server/drizzle/meta/0025_snapshot.json +5474 -0
  47. package/apps/server/drizzle/meta/0026_snapshot.json +5481 -0
  48. package/apps/server/drizzle/meta/0027_snapshot.json +5605 -0
  49. package/apps/server/drizzle/meta/0028_snapshot.json +5699 -0
  50. package/apps/server/drizzle/meta/0029_snapshot.json +5809 -0
  51. package/apps/server/drizzle/meta/0030_snapshot.json +6066 -0
  52. package/apps/server/drizzle/meta/0031_snapshot.json +6074 -0
  53. package/apps/server/drizzle/meta/0032_snapshot.json +6100 -0
  54. package/apps/server/drizzle/meta/0033_snapshot.json +6406 -0
  55. package/apps/server/drizzle/meta/0034_snapshot.json +6698 -0
  56. package/apps/server/drizzle/meta/0035_snapshot.json +6704 -0
  57. package/apps/server/drizzle/meta/0036_snapshot.json +6825 -0
  58. package/apps/server/drizzle/meta/0037_snapshot.json +6825 -0
  59. package/apps/server/drizzle/meta/0038_snapshot.json +6998 -0
  60. package/apps/server/drizzle/meta/_journal.json +175 -0
  61. package/apps/web/dist/assets/CompositeItem-DgnfVvrF.js +1 -0
  62. package/apps/web/dist/assets/Trans-f2VYVS9z.js +1 -0
  63. package/apps/web/dist/assets/abnfDiagram-WEYOIA3C-DTOx8N4B.js +1 -0
  64. package/apps/web/dist/assets/admin-settings-DBZL4siY.js +1 -0
  65. package/apps/web/dist/assets/arc-Y1ARJVZ4.js +1 -0
  66. package/apps/web/dist/assets/architecture-WOLXFQ4H-DlC3p5z3.js +1 -0
  67. package/apps/web/dist/assets/architectureDiagram-ZHVYKT53-DooN3Z58.js +36 -0
  68. package/apps/web/dist/assets/archive-restore-CGuLxoHC.js +1 -0
  69. package/apps/web/dist/assets/array-BifhSqXX.js +1 -0
  70. package/apps/web/dist/assets/az-AZ-DesqXNg5.js +1 -0
  71. package/apps/web/dist/assets/az-BE5iqNIE.js +1 -0
  72. package/apps/web/dist/assets/az-D-kj4lmf.js +1 -0
  73. package/apps/web/dist/assets/blockDiagram-RJAE7QA3-CD5AgY4U.js +140 -0
  74. package/apps/web/dist/assets/c4Diagram-XDPPPE54-CTDOcLuN.js +38 -0
  75. package/apps/web/dist/assets/channel-BhHvU4I-.js +1 -0
  76. package/apps/web/dist/assets/chunk-2E7WHVMD-D9qnBqTV.js +88 -0
  77. package/apps/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  78. package/apps/web/dist/assets/chunk-4EMNKR2T-Kz5JiJ85.js +1 -0
  79. package/apps/web/dist/assets/chunk-55XCU76K-DnKiFpYM.js +1 -0
  80. package/apps/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  81. package/apps/web/dist/assets/chunk-7S6XSTFO-CpdNd6Ke.js +2 -0
  82. package/apps/web/dist/assets/chunk-AQDTSKZB-CpyuSWt8.js +72 -0
  83. package/apps/web/dist/assets/chunk-DSNLSHJZ-DTC3y9rb.js +129 -0
  84. package/apps/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  85. package/apps/web/dist/assets/chunk-HAGFOTED-Bv8PrMYK.js +62 -0
  86. package/apps/web/dist/assets/chunk-HNE4Q36P-CDDJ_yRa.js +1 -0
  87. package/apps/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  88. package/apps/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  89. package/apps/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  90. package/apps/web/dist/assets/chunk-NQOKRVIF-lNtnsWHS.js +213 -0
  91. package/apps/web/dist/assets/chunk-UHW7PFBP-D79ptlTN.js +1 -0
  92. package/apps/web/dist/assets/chunk-VQ5W62QI-B8hWFCiM.js +1 -0
  93. package/apps/web/dist/assets/chunk-WQQL4CDK-DAGuWzS3.js +10 -0
  94. package/apps/web/dist/assets/chunk-XXDRQBXY-CnbRkih_.js +1 -0
  95. package/apps/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  96. package/apps/web/dist/assets/chunk-YZO67HFG-C0ptzlKO.js +1 -0
  97. package/apps/web/dist/assets/chunk-ZAUX2LTX-ah8U5xoJ.js +1 -0
  98. package/apps/web/dist/assets/chunk-ZCHMQSOR-DYqATOIZ.js +2 -0
  99. package/apps/web/dist/assets/classDiagram-v2-4MYWR3UA-DYytA-ua.js +217 -0
  100. package/apps/web/dist/assets/core-jXc03Sty.js +1 -0
  101. package/apps/web/dist/assets/corner-left-up-BCro--Gl.js +1 -0
  102. package/apps/web/dist/assets/cose-bilkent-JH36ORCC-lWVj4msc.js +1 -0
  103. package/apps/web/dist/assets/cynefin-EF2NZ3EQ-Xy6e6m1E.js +1 -0
  104. package/apps/web/dist/assets/cynefinDiagram-WF24UDRP-BYarZJSm.js +62 -0
  105. package/apps/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  106. package/apps/web/dist/assets/dagre-NK4GIZVX-WLvvUY1o.js +4 -0
  107. package/apps/web/dist/assets/de-DAv77-wP.js +1 -0
  108. package/apps/web/dist/assets/de-DE-BIGY9SNm.js +1 -0
  109. package/apps/web/dist/assets/de-DLGjUGf_.js +1 -0
  110. package/apps/web/dist/assets/default-BgvtI7HK.js +56 -0
  111. package/apps/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  112. package/apps/web/dist/assets/delivery-settings-CZOtb4X8.js +1 -0
  113. package/apps/web/dist/assets/diagram-CBK3MWTC-D6PtxkLg.js +24 -0
  114. package/apps/web/dist/assets/diagram-I5XIIV2D-DTOKQwIY.js +24 -0
  115. package/apps/web/dist/assets/diagram-ODIL2UCW-BmERt0Xu.js +30 -0
  116. package/apps/web/dist/assets/diagram-PFBEJUXE-WDUe08eD.js +3 -0
  117. package/apps/web/dist/assets/diagram-RJA3I4VV-B2K-NLGG.js +41 -0
  118. package/apps/web/dist/assets/diagram-TYUNFSNR-BLf7PpV7.js +200 -0
  119. package/apps/web/dist/assets/dist-CumnGVmJ.js +1 -0
  120. package/apps/web/dist/assets/ebnfDiagram-V4P3TTPT-DwBUQs9p.js +1 -0
  121. package/apps/web/dist/assets/el-CG6Xy42v.js +1 -0
  122. package/apps/web/dist/assets/el-D2ypddWn.js +1 -0
  123. package/apps/web/dist/assets/el-GR-D2TjImt0.js +1 -0
  124. package/apps/web/dist/assets/elk-QONTBCTQ-AuT8PinL.js +27 -0
  125. package/apps/web/dist/assets/ellipsis-ZJM26JNS.js +1 -0
  126. package/apps/web/dist/assets/en-EnSRisMz.js +1 -0
  127. package/apps/web/dist/assets/en-US-DKYSujv3.js +1 -0
  128. package/apps/web/dist/assets/erDiagram-KYQIGQAA-h--u4Eo6.js +99 -0
  129. package/apps/web/dist/assets/es-B_49BMhR.js +1 -0
  130. package/apps/web/dist/assets/es-Bbj5WuMM.js +1 -0
  131. package/apps/web/dist/assets/es-ES-C_fRqLGU.js +1 -0
  132. package/apps/web/dist/assets/eventmodeling-K75KTNOO-CW4hDt1j.js +1 -0
  133. package/apps/web/dist/assets/flowDiagram-IUGLLWBU-BGUscnNP.js +1 -0
  134. package/apps/web/dist/assets/forms-C3pNy-qY.js +1 -0
  135. package/apps/web/dist/assets/fr-B-WmPGZD.js +1 -0
  136. package/apps/web/dist/assets/fr-CjuIKc1o.js +1 -0
  137. package/apps/web/dist/assets/fr-FR-_tnQsn4j.js +1 -0
  138. package/apps/web/dist/assets/ganttDiagram-R7GPNTJY-CB3lsQvj.js +292 -0
  139. package/apps/web/dist/assets/gitGraph-VSP46ZUC-Hu-kvdCR.js +1 -0
  140. package/apps/web/dist/assets/gitGraphDiagram-PDYL7XHY-DCuY3C1Q.js +106 -0
  141. package/apps/web/dist/assets/graphlib-DS17s2tU.js +1 -0
  142. package/apps/web/dist/assets/hi-BVVrZ-zZ.js +1 -0
  143. package/apps/web/dist/assets/hi-DvoDcUpL.js +1 -0
  144. package/apps/web/dist/assets/hi-IN-CcmFsZYy.js +1 -0
  145. package/apps/web/dist/assets/highlight-CKyEM4jx.js +5 -0
  146. package/apps/web/dist/assets/id-BhNWOQnp.js +1 -0
  147. package/apps/web/dist/assets/id-CPASBZ2r.js +1 -0
  148. package/apps/web/dist/assets/id-ID-C59tC61D.js +1 -0
  149. package/apps/web/dist/assets/index-BIuqxnJn.js +17 -0
  150. package/apps/web/dist/assets/index-DyQNFS69.css +2 -0
  151. package/apps/web/dist/assets/info-OHQRW6UA-Cc8VwTsk.js +1 -0
  152. package/apps/web/dist/assets/infoDiagram-MVDRKAX5-KIMOx55J.js +2 -0
  153. package/apps/web/dist/assets/init-C-OQMol4.js +1 -0
  154. package/apps/web/dist/assets/invitations-D5unaga2.js +1 -0
  155. package/apps/web/dist/assets/isSameWeek-2ftYGvBT.js +1 -0
  156. package/apps/web/dist/assets/ishikawaDiagram-HPEHV2GE-CAou726L.js +70 -0
  157. package/apps/web/dist/assets/it-Cav3_-xd.js +1 -0
  158. package/apps/web/dist/assets/it-Cd7u97Jd.js +1 -0
  159. package/apps/web/dist/assets/it-IT-BAP5O3gK.js +1 -0
  160. package/apps/web/dist/assets/item-Cj8xpMLk.js +1 -0
  161. package/apps/web/dist/assets/ja-DTmZE0Qe.js +1 -0
  162. package/apps/web/dist/assets/ja-JP-vQa7mEdh.js +1 -0
  163. package/apps/web/dist/assets/ja-LkOrjegp.js +1 -0
  164. package/apps/web/dist/assets/journeyDiagram-BOSHW7O7-YbJuPYDH.js +139 -0
  165. package/apps/web/dist/assets/kanban-definition-FNSA4HC5-nuRnm9y8.js +89 -0
  166. package/apps/web/dist/assets/katex-B_JcIwqj.js +257 -0
  167. package/apps/web/dist/assets/ko-BuYCMOUj.js +1 -0
  168. package/apps/web/dist/assets/ko-DPpXw683.js +1 -0
  169. package/apps/web/dist/assets/ko-KR-CXlpmX0-.js +1 -0
  170. package/apps/web/dist/assets/label-settings-B89Ehf78.js +1 -0
  171. package/apps/web/dist/assets/line-WsxZYwZr.js +1 -0
  172. package/apps/web/dist/assets/linear-C9iwaMOi.js +1 -0
  173. package/apps/web/dist/assets/lucide-DIlszs-T.js +1 -0
  174. package/apps/web/dist/assets/markdown-blocks-DSEA68s5.js +2 -0
  175. package/apps/web/dist/assets/member-directory-48yD01yN.js +1 -0
  176. package/apps/web/dist/assets/mermaid-parser.core-B5c-1CnP.js +7 -0
  177. package/apps/web/dist/assets/mermaid.core-vGVkgzA3.js +44 -0
  178. package/apps/web/dist/assets/mindmap-definition-6OY57UG3-uooJyUak.js +98 -0
  179. package/apps/web/dist/assets/mk-BZ2Rg2sU.js +1 -0
  180. package/apps/web/dist/assets/mk-CkaQRpqe.js +1 -0
  181. package/apps/web/dist/assets/mk-MK-BU5BLpXM.js +1 -0
  182. package/apps/web/dist/assets/nl-BtnPzRih.js +1 -0
  183. package/apps/web/dist/assets/nl-DiEjqOA8.js +1 -0
  184. package/apps/web/dist/assets/nl-NL-2aSpdZkg.js +1 -0
  185. package/apps/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  186. package/apps/web/dist/assets/packet-JDAUHWVQ-WJpqlH1d.js +1 -0
  187. package/apps/web/dist/assets/path-fybaL0A-.js +1 -0
  188. package/apps/web/dist/assets/pegDiagram-ECIUJNBX-CUvajD93.js +1 -0
  189. package/apps/web/dist/assets/pie-XZMESJXO-DvandPby.js +1 -0
  190. package/apps/web/dist/assets/pieDiagram-GNMPY74T-CqxeyIaP.js +39 -0
  191. package/apps/web/dist/assets/pl-Beg8hEdK.js +1 -0
  192. package/apps/web/dist/assets/pl-CauoMfkf.js +1 -0
  193. package/apps/web/dist/assets/pl-PL-C5Wvnv7f.js +1 -0
  194. package/apps/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  195. package/apps/web/dist/assets/project-lifecycle-CIAMJ-SX.js +1 -0
  196. package/apps/web/dist/assets/project-settings-C7lolDSl.js +1 -0
  197. package/apps/web/dist/assets/pt-BR-B4VZ35N_.js +1 -0
  198. package/apps/web/dist/assets/pt-BR-BpOhwoDW.js +1 -0
  199. package/apps/web/dist/assets/pt-BR-CVvI-ZB7.js +1 -0
  200. package/apps/web/dist/assets/public-project-DvvfaUd2.js +1 -0
  201. package/apps/web/dist/assets/quadrantDiagram-D57K4MC3-DFjNmaZc.js +7 -0
  202. package/apps/web/dist/assets/radar-ABXABTNO-CJhZF9j2.js +1 -0
  203. package/apps/web/dist/assets/railroad-I3PHUGI6-DZC10oBD.js +1 -0
  204. package/apps/web/dist/assets/railroad-abnf-LNEFI6M7-BUJF2n6u.js +1 -0
  205. package/apps/web/dist/assets/railroad-ebnf-DWYD2UWJ-CG7oB02K.js +1 -0
  206. package/apps/web/dist/assets/railroad-peg-JR7BVNR7-DBCZuZJY.js +1 -0
  207. package/apps/web/dist/assets/railroadDiagram-Y72NRBL7-CvN3poBq.js +1 -0
  208. package/apps/web/dist/assets/requirementDiagram-EQYSW5RA-BEItNirc.js +84 -0
  209. package/apps/web/dist/assets/rolldown-runtime-COiEfhsA.js +1 -0
  210. package/apps/web/dist/assets/rotate-ccw-GjVJPDML.js +1 -0
  211. package/apps/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  212. package/apps/web/dist/assets/ru-DOaRp00B.js +1 -0
  213. package/apps/web/dist/assets/ru-DjVJVHzv.js +1 -0
  214. package/apps/web/dist/assets/ru-RU-C7UUdGIh.js +1 -0
  215. package/apps/web/dist/assets/sankeyDiagram-SY24JHO5-DluLIjax.js +40 -0
  216. package/apps/web/dist/assets/search-ZaQmVnj1.js +85 -0
  217. package/apps/web/dist/assets/send-Dr9bXHhf.js +1 -0
  218. package/apps/web/dist/assets/sequenceDiagram-BLEHNHQU-ZcwnsNxK.js +169 -0
  219. package/apps/web/dist/assets/settings-DXvrVvyS.js +1 -0
  220. package/apps/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  221. package/apps/web/dist/assets/sliders-horizontal-C1t_zA7D.js +1 -0
  222. package/apps/web/dist/assets/src-BWF7jMDv.js +1 -0
  223. package/apps/web/dist/assets/stateDiagram-v2-A6IESKWZ-DD6vH3rM.js +291 -0
  224. package/apps/web/dist/assets/swimlanes-B3ENRKFQ-pX_qyO4X.js +1 -0
  225. package/apps/web/dist/assets/swimlanesDiagram-AYQRV4AK-CeVsLUsg.js +8 -0
  226. package/apps/web/dist/assets/switch-D-WTDajm.js +1 -0
  227. package/apps/web/dist/assets/table-BaRMFP84.js +1 -0
  228. package/apps/web/dist/assets/ticket-detail-CFJI4qoK.js +2 -0
  229. package/apps/web/dist/assets/ticket-editor-DL3nHG_q.js +2 -0
  230. package/apps/web/dist/assets/ticket-list-CDkweYGS.js +5 -0
  231. package/apps/web/dist/assets/ticket-list-controls-CmTgFQQ-.js +1 -0
  232. package/apps/web/dist/assets/timeline-definition-JXYKGLGB-DLBjAxKq.js +120 -0
  233. package/apps/web/dist/assets/toDate-B3HYxwtT.js +1 -0
  234. package/apps/web/dist/assets/tr-Ckc4TA71.js +1 -0
  235. package/apps/web/dist/assets/tr-DeYojjLx.js +1 -0
  236. package/apps/web/dist/assets/tr-TR-VVsL3uMp.js +1 -0
  237. package/apps/web/dist/assets/treeView-D4JQ5SDB-DsJ44q_q.js +1 -0
  238. package/apps/web/dist/assets/treemap-SAJKECNS-BmXoBpVL.js +1 -0
  239. package/apps/web/dist/assets/uk-B4ZvJ8xG.js +1 -0
  240. package/apps/web/dist/assets/uk-DACrfgCP.js +1 -0
  241. package/apps/web/dist/assets/uk-UA-DRpCbOps.js +1 -0
  242. package/apps/web/dist/assets/upload-BwGa8shS.js +1 -0
  243. package/apps/web/dist/assets/useQueries-sQEHjB0a.js +1 -0
  244. package/apps/web/dist/assets/usecaseDiagram-WPOLBNNX-eU2tr2si.js +328 -0
  245. package/apps/web/dist/assets/vennDiagram-Q3Y3IDCW-CoVTWGNg.js +34 -0
  246. package/apps/web/dist/assets/vi-CkqUo4w_.js +1 -0
  247. package/apps/web/dist/assets/vi-Dq2krfY8.js +1 -0
  248. package/apps/web/dist/assets/vi-VN-C0YFqq9j.js +1 -0
  249. package/apps/web/dist/assets/visual-editor-CBhwqTDu.js +208 -0
  250. package/apps/web/dist/assets/wardley-7MLQ67FV-C6mVVk98.js +1 -0
  251. package/apps/web/dist/assets/wardleyDiagram-IVAIDKWA-m3CeJc7a.js +78 -0
  252. package/apps/web/dist/assets/workspace-settings-DgLyieJN.js +1 -0
  253. package/apps/web/dist/assets/xychartDiagram-4URNHDU4-Dw6fLaip.js +7 -0
  254. package/apps/web/dist/assets/zh-CN-B7Rxc3Mp.js +1 -0
  255. package/apps/web/dist/assets/zh-CN-CC7ghLLr.js +1 -0
  256. package/apps/web/dist/assets/zh-CN-pcgTdR8c.js +1 -0
  257. package/apps/web/dist/assets/zh-TW-BlIRMi1C.js +1 -0
  258. package/apps/web/dist/assets/zh-TW-DymSXkFg.js +1 -0
  259. package/apps/web/dist/assets/zh-TW-awiwHjeM.js +1 -0
  260. package/apps/web/dist/index.html +14 -8
  261. package/bin/kanvil.mjs +3 -1
  262. package/compose.yml +12 -0
  263. package/docs/api.md +311 -10
  264. package/docs/cli.md +74 -0
  265. package/docs/infrastructure.md +2 -2
  266. package/docs/localization.md +25 -0
  267. package/docs/plan.md +34 -11
  268. package/docs/self-hosting.md +44 -5
  269. package/docs/themes.md +1 -1
  270. package/docs/verification.md +1 -1
  271. package/docs/workspaces.md +18 -2
  272. package/package.json +12 -8
  273. package/apps/server/dist/auth-I1XEr0d4.mjs +0 -2931
  274. package/apps/web/dist/assets/CompositeItem-BWPJ5Mol.js +0 -1
  275. package/apps/web/dist/assets/admin-settings-DZqkC6ju.js +0 -1
  276. package/apps/web/dist/assets/date-picker-6H_iwP6t.js +0 -1
  277. package/apps/web/dist/assets/ellipsis-BT172Uvu.js +0 -1
  278. package/apps/web/dist/assets/forms-B4Vg3CPz.js +0 -92
  279. package/apps/web/dist/assets/image-edit-trigger-DGmVtWLC.js +0 -1
  280. package/apps/web/dist/assets/index-C_a21taR.js +0 -50
  281. package/apps/web/dist/assets/index-DM-D_3Sv.css +0 -2
  282. package/apps/web/dist/assets/kbd-tTQGZxaK.js +0 -1
  283. package/apps/web/dist/assets/link-DoIP9KJ6.js +0 -2
  284. package/apps/web/dist/assets/list-order-Bzsrihyr.js +0 -1
  285. package/apps/web/dist/assets/lucide-U995NW8E.js +0 -1
  286. package/apps/web/dist/assets/member-directory-BiCE8DRq.js +0 -1
  287. package/apps/web/dist/assets/popover-BhzZ0U1d.js +0 -1
  288. package/apps/web/dist/assets/project-label-provider-9L7Uts3_.js +0 -1
  289. package/apps/web/dist/assets/project-settings-Q7Q9Reec.js +0 -1
  290. package/apps/web/dist/assets/rotate-ccw-sjyLwLLy.js +0 -1
  291. package/apps/web/dist/assets/settings-B6qD6q1J.js +0 -1
  292. package/apps/web/dist/assets/settings-layout-B-AwUIMu.js +0 -1
  293. package/apps/web/dist/assets/switch-D0nFHj6l.js +0 -1
  294. package/apps/web/dist/assets/table-CYZiwo9g.js +0 -1
  295. package/apps/web/dist/assets/ticket-detail-B-3vbHSB.js +0 -2
  296. package/apps/web/dist/assets/ticket-editor-C3d8TZIT.js +0 -1
  297. package/apps/web/dist/assets/ticket-list-LtGGOnwN.js +0 -5
  298. package/apps/web/dist/assets/ticket-tags-2rdmkcJV.js +0 -1
  299. package/apps/web/dist/assets/upload-DXPRzAaa.js +0 -1
  300. package/apps/web/dist/assets/useNavigate-DFohfUD3.js +0 -1
  301. package/apps/web/dist/assets/workspace-settings-D3tCm1vH.js +0 -1
@@ -1,12 +1,16 @@
1
- import { a as instanceUpdateSchema, c as s3SettingsSchema, d as createDatabase, f as account, g as user, h as session, i as brokerSettingsSchema, n as adminInstanceSchema, o as photoInputSchema, p as apikey, s as photoResultSchema, t as readConfig, u as searchSettingsSchema } from "./config-CbNqtJEj.mjs";
1
+ import { a as auth_schema_exports, d as user$1, i as apikey, n as createDatabase, r as account, t as readConfig, u as session } from "./config-DtNfVSnB.mjs";
2
+ import { a as photoInputSchema, i as instanceUpdateSchema, l as searchSettingsSchema, o as photoResultSchema, r as brokerSettingsSchema, s as s3SettingsSchema, t as adminInstanceSchema } from "./instance-CCeXRJ6m.mjs";
2
3
  import { t as migrateDatabase } from "./migrate-C9f4ozB4.mjs";
3
- import { A as pushDevices, B as memberSchema, D as profilePhotos, E as notifications, F as workspaces, G as pushDeviceInput, H as notificationPreferencesSchema, I as ApiError, J as matchesOIDCBinding, K as roles, L as errorSchema, M as subscriptions, N as tickets, O as projects, P as workspaceMembers, R as hasAdminRole, S as events, T as notificationPreferences, U as okSchema, V as notificationPreferencesInput, W as operations, _ as attachmentContent, a as fail, b as deliveryCheckpoints, c as requireRow, d as createTicketIndex, f as createBlobStorage, g as createPollingJob, h as createObjectStore, i as createDomain, j as storageBackends, k as pushDeliveries, l as projectRoleSQL, m as imageResponse, n as createInstance, o as json, p as decodeImage, q as scopes, r as assertVersion, s as one, t as createAuth, u as createTicketSearch, v as attachments, w as members, x as eventClock, y as blobObjects, z as isOperation } from "./auth-I1XEr0d4.mjs";
4
+ import { $ as subscriptions, A as createTicketSearch, B as blobObjects, C as DeliveryError, D as requireInstanceAdmin, E as createWriteLimiter, F as stagedUploadCutoff, G as members, H as eventClock, I as createObjectStore, J as profilePhotos, K as notificationPreferences, L as createPollingJob, M as createBlobStorage, N as decodeImage, O as requireSession, P as imageResponse, Q as storageBackends, R as attachmentContent, S as deliverBatch, T as createAccess, U as events, V as deliveryCheckpoints, X as pushDevices, Y as projects, Z as stagedUploads, _ as recipientColumns, a as deliverySecrets, b as receiveRepositoryWebhook, c as ownerBlocker, d as createDomain, et as tickets, f as fail, g as reachable, h as one, i as createInstance, j as createTicketIndex, k as readCalendarFeed, l as appendEvents, m as notify, n as emailCodesAvailable, nt as workspaces, o as otherActiveAdministrator, p as json, q as notifications, r as signInProviders, rt as matchesOIDCBinding, s as ownedWorkspaces, t as createAuth, tt as workspaceMembers, u as assertVersion, v as requireRow, w as createHttpSender, x as defaultSenders, y as processImports, z as attachments } from "./auth-Cuo7ECLh.mjs";
5
+ import { A as importProviderDefinitions, B as notificationIdsInput, F as isPresetColor, G as okSchema, H as notificationPreferencesSchema, I as isPublicOperation, O as hasAdminRole, P as isOperation, R as memberSchema, St as ticketExportFormat, U as notificationReadInput, V as notificationPreferencesInput, ft as roles, it as pushDeviceInput, k as importLimits, m as defaultWorkflow, o as closedStates, pt as scopes, q as operations, rt as publicOperations, s as credentialInput, t as ApiError, x as errorSchema, yt as stateLabels } from "./src-tQ82UI8k.mjs";
4
6
  import { serve } from "@hono/node-server";
5
7
  import pg from "pg";
6
- import { and, asc, count, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
8
+ import { drizzle } from "drizzle-orm/node-postgres";
9
+ import { and, asc, count, desc, eq, gt, ilike, inArray, isNotNull, isNull, notInArray, or, sql } from "drizzle-orm";
7
10
  import { z } from "zod";
8
11
  import { fileURLToPath } from "node:url";
9
- import { createHash, timingSafeEqual } from "node:crypto";
12
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
13
+ import { Expo } from "expo-server-sdk";
10
14
  import { isDeepStrictEqual } from "node:util";
11
15
  import { EventEmitter } from "node:events";
12
16
  import { connect } from "amqplib";
@@ -18,7 +22,7 @@ import { streamSSE } from "hono/streaming";
18
22
  import { serveStatic } from "@hono/node-server/serve-static";
19
23
  import { APIError } from "better-auth/api";
20
24
  import { requireMcpAuth } from "@better-auth/mcp";
21
- import { Expo } from "expo-server-sdk";
25
+ import TurndownService from "turndown";
22
26
  import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
23
27
  //#region ../../packages/contracts/src/users.ts
24
28
  const accountSchema = memberSchema.pick({
@@ -65,22 +69,46 @@ const accountDetailsSchema = accountSchema.extend({
65
69
  sessionCount: z.number().int().nonnegative()
66
70
  });
67
71
  const localPasswordSchema = z.object({ password: z.string().min(12).max(128) }).strict();
72
+ const adminWorkspaceQuerySchema = accountQuerySchema.pick({
73
+ q: true,
74
+ limit: true,
75
+ offset: true
76
+ });
77
+ const adminWorkspacesSchema = z.object({
78
+ workspaces: z.array(z.object({
79
+ id: z.uuid(),
80
+ name: z.string(),
81
+ slug: z.string(),
82
+ ownerId: z.string().nullable().default(null),
83
+ createdAt: z.iso.datetime(),
84
+ memberCount: z.number().int().nonnegative(),
85
+ projectCount: z.number().int().nonnegative(),
86
+ administrators: z.array(accountSchema.pick({
87
+ id: true,
88
+ name: true,
89
+ email: true,
90
+ image: true
91
+ }))
92
+ })),
93
+ total: z.number().int().nonnegative()
94
+ });
68
95
  //#endregion
69
96
  //#region src/account-administration.ts
70
97
  const person = {
71
- id: user.id,
72
- name: user.name,
73
- email: user.email,
74
- image: user.image,
75
- role: user.role,
76
- banned: sql`coalesce(${user.banned}, false)`,
77
- createdAt: user.createdAt
98
+ id: user$1.id,
99
+ name: user$1.name,
100
+ email: user$1.email,
101
+ image: user$1.image,
102
+ role: user$1.role,
103
+ banned: sql`coalesce(${user$1.banned}, false)`,
104
+ createdAt: user$1.createdAt
78
105
  };
79
106
  const mutationBody = z.object({
80
107
  userId: z.string().optional(),
81
108
  role: z.union([z.string(), z.array(z.string())]).optional(),
82
109
  data: z.record(z.string(), z.unknown()).optional()
83
110
  }).passthrough();
111
+ const pattern = (q) => `%${q.replace(/[\\%_]/g, "\\$&")}%`;
84
112
  function authPath(request) {
85
113
  try {
86
114
  return decodeURIComponent(new URL(request.url).pathname).replace(/\/+/g, "/").replace(/\/$/, "");
@@ -99,7 +127,7 @@ function createAccountAdministration(database, auth, config) {
99
127
  fail(503, "session_revocation_failed", "The password changed, but existing sessions could not be revoked. Revoke this user’s sessions before continuing.");
100
128
  }
101
129
  }
102
- async function mutate(request, run, administratorOnly = true) {
130
+ async function mutate(request, run, administratorOnly = true, subjectId) {
103
131
  const login = await auth.api.getSession({ headers: request.headers });
104
132
  if (!login) fail(401, "unauthorized", "Sign in to continue");
105
133
  if (administratorOnly && !hasAdminRole(login.user.role) || login.user.banned) fail(403, "forbidden", "Instance administrator access is required");
@@ -108,6 +136,7 @@ function createAccountAdministration(database, auth, config) {
108
136
  try {
109
137
  await lock.query("SELECT pg_advisory_lock(7361083)");
110
138
  locked = true;
139
+ await lock.query("SELECT pg_advisory_lock(7361080)");
111
140
  const path = authPath(request);
112
141
  if (path === "/api/auth/unlink-account") {
113
142
  if (!one((await lock.query("SELECT email_password_enabled AS enabled FROM instance_settings WHERE id=1")).rows).enabled) {
@@ -116,25 +145,29 @@ function createAccountAdministration(database, auth, config) {
116
145
  if (!(await lock.query(`SELECT account_id AS "accountId", id_token AS "idToken" FROM account WHERE user_id=$1 AND provider_id='oidc' AND id<>$2`, [login.user.id, accountId])).rows.some((binding) => matchesOIDCBinding(binding, issuer, config.OIDC_CLIENT_ID))) fail(409, "oidc_binding_required", "Enable password sign-in before unlinking your last account for the configured OIDC provider");
117
146
  }
118
147
  }
119
- let passwordUserId;
120
- if (path.endsWith("/admin/set-user-password")) passwordUserId = mutationBody.parse(await request.clone().json()).userId;
121
- if ([
122
- "set-role",
123
- "update-user",
124
- "ban-user",
125
- "remove-user"
126
- ].some((name) => path.endsWith(`/admin/${name}`))) {
127
- const body = mutationBody.parse(await request.clone().json());
128
- if (body.userId) {
129
- const current = one((await lock.query(`SELECT id, role, coalesce(banned,false) AS banned FROM "user" WHERE id = $1`, [body.userId])).rows);
148
+ if (path.endsWith("/admin/remove-user")) fail(403, "forbidden", "Delete accounts with delete_account so their history stays intact");
149
+ const body = path.startsWith("/api/auth/admin/") ? mutationBody.parse(await request.clone().json()) : void 0;
150
+ const userId = subjectId ?? body?.userId;
151
+ const passwordUserId = path.endsWith("/admin/set-user-password") ? userId : void 0;
152
+ if (administratorOnly && userId) {
153
+ const current = one((await lock.query(`SELECT id, role, coalesce(banned,false) AS banned FROM "user" WHERE id = $1 AND deleted_at IS NULL`, [userId])).rows);
154
+ if (body && [
155
+ "set-role",
156
+ "update-user",
157
+ "ban-user"
158
+ ].some((name) => path.endsWith(`/admin/${name}`))) {
130
159
  const role = body.role ?? body.data?.role ?? current.role;
131
160
  const candidate = z.union([z.string(), z.array(z.string())]).nullable().safeParse(role);
132
161
  const nextRole = candidate.success ? Array.isArray(candidate.data) ? candidate.data.join(",") : candidate.data : current.role;
133
162
  if (body.data && Object.hasOwn(body.data, "banned")) z.boolean().parse(body.data.banned);
134
163
  const banned = path.endsWith("/ban-user") || body.data?.banned === true || body.data?.banned === void 0 && current.banned;
135
- if (hasAdminRole(current.role) && !current.banned && (!hasAdminRole(nextRole) || banned || path.endsWith("/remove-user"))) {
136
- if (!(await lock.query(`SELECT id FROM "user" WHERE id <> $1 AND NOT coalesce(banned,false) AND 'admin' = ANY(string_to_array(role, ',')) LIMIT 1`, [current.id])).rowCount) fail(409, "last_administrator", "Keep at least one active instance administrator");
164
+ const guard = drizzle(lock, { schema: auth_schema_exports });
165
+ if (banned && !current.banned) {
166
+ const owned = await ownedWorkspaces(guard, current.id);
167
+ const [blocker] = ownerBlocker(owned);
168
+ if (blocker) fail(409, blocker.code, blocker.message, { workspaces: owned });
137
169
  }
170
+ if (hasAdminRole(current.role) && !current.banned && (!hasAdminRole(nextRole) || banned) && !await otherActiveAdministrator(guard, current.id)) fail(409, "last_administrator", "Keep at least one active instance administrator");
138
171
  }
139
172
  }
140
173
  const response = await run();
@@ -142,7 +175,7 @@ function createAccountAdministration(database, auth, config) {
142
175
  return response;
143
176
  } finally {
144
177
  try {
145
- if (locked) await lock.query("SELECT pg_advisory_unlock(7361083)");
178
+ if (locked) await lock.query("SELECT pg_advisory_unlock_all()");
146
179
  } finally {
147
180
  lock.release();
148
181
  }
@@ -157,7 +190,8 @@ function createAccountAdministration(database, auth, config) {
157
190
  "ban-user",
158
191
  "unban-user",
159
192
  "remove-user",
160
- "set-user-password"
193
+ "set-user-password",
194
+ "impersonate-user"
161
195
  ].some((name) => path === `/api/auth/admin/${name}`);
162
196
  const unlink = path === "/api/auth/unlink-account";
163
197
  return request.method === "POST" && (guarded || unlink) ? mutate(request, () => auth.handler(request), !unlink) : auth.handler(request);
@@ -183,6 +217,16 @@ function createAccountAdministration(database, auth, config) {
183
217
  content: { "application/json": { schema: accountDetailsSchema } }
184
218
  } }
185
219
  });
220
+ app.openAPIRegistry.registerPath({
221
+ method: "get",
222
+ path: "/api/v1/admin/workspaces",
223
+ security: [{ sessionCookie: [] }],
224
+ request: { query: adminWorkspaceQuerySchema },
225
+ responses: { 200: {
226
+ description: "Every workspace with member and project counts and its administrators",
227
+ content: { "application/json": { schema: adminWorkspacesSchema } }
228
+ } }
229
+ });
186
230
  app.openAPIRegistry.registerPath({
187
231
  method: "post",
188
232
  path: "/api/v1/admin/users/{id}/password",
@@ -200,17 +244,17 @@ function createAccountAdministration(database, auth, config) {
200
244
  } }
201
245
  });
202
246
  app.get("/api/v1/admin/users", async (c) => {
203
- const input = accountQuerySchema.parse(c.req.query()), needle = `%${input.q.replace(/[\\%_]/g, "\\$&")}%`;
204
- const administrator = sql`'admin' = ANY(string_to_array(${user.role}, ','))`;
205
- const filter = and(input.q ? or(ilike(user.name, needle), ilike(user.email, needle)) : void 0, input.role === "admin" ? administrator : input.role === "user" ? sql`NOT coalesce(${administrator},false)` : void 0, input.status === "all" ? void 0 : input.status === "disabled" ? eq(user.banned, true) : sql`NOT coalesce(${user.banned},false)`);
206
- const [users, total] = await Promise.all([database.db.select(person).from(user).where(filter).orderBy(asc(user.name), asc(user.id)).limit(input.limit).offset(input.offset), database.db.select({ value: count() }).from(user).where(filter)]);
247
+ const input = accountQuerySchema.parse(c.req.query()), needle = pattern(input.q);
248
+ const administrator = sql`'admin' = ANY(string_to_array(${user$1.role}, ','))`;
249
+ const filter = and(isNull(user$1.deletedAt), input.q ? or(ilike(user$1.name, needle), ilike(user$1.email, needle)) : void 0, input.role === "admin" ? administrator : input.role === "user" ? sql`NOT coalesce(${administrator},false)` : void 0, input.status === "all" ? void 0 : input.status === "disabled" ? eq(user$1.banned, true) : sql`NOT coalesce(${user$1.banned},false)`);
250
+ const [users, total] = await Promise.all([database.db.select(person).from(user$1).where(filter).orderBy(asc(user$1.name), asc(user$1.id)).limit(input.limit).offset(input.offset), database.db.select({ value: count() }).from(user$1).where(filter)]);
207
251
  return Response.json(json({
208
252
  users,
209
253
  total: total[0]?.value ?? 0
210
254
  }));
211
255
  });
212
256
  app.get("/api/v1/admin/users/:id", async (c) => {
213
- const id = c.req.param("id"), row = one(await database.db.select(person).from(user).where(eq(user.id, id)));
257
+ const id = c.req.param("id"), row = one(await database.db.select(person).from(user$1).where(and(eq(user$1.id, id), isNull(user$1.deletedAt))));
214
258
  const [spaces, direct, providers, sessions] = await Promise.all([
215
259
  database.db.select({
216
260
  id: workspaces.id,
@@ -233,6 +277,38 @@ function createAccountAdministration(database, auth, config) {
233
277
  sessionCount: sessions[0]?.value ?? 0
234
278
  }));
235
279
  });
280
+ app.get("/api/v1/admin/workspaces", async (c) => {
281
+ const input = adminWorkspaceQuerySchema.parse(c.req.query()), needle = pattern(input.q);
282
+ const filter = and(isNull(workspaces.deletedAt), input.q ? or(ilike(workspaces.name, needle), ilike(workspaces.slug, needle)) : void 0);
283
+ const [rows, total] = await Promise.all([database.db.select({
284
+ id: workspaces.id,
285
+ name: workspaces.name,
286
+ slug: workspaces.slug,
287
+ ownerId: workspaces.ownerId,
288
+ createdAt: workspaces.createdAt,
289
+ memberCount: sql`(SELECT count(*)::int FROM workspace_members m WHERE m.workspace_id = workspaces.id)`,
290
+ projectCount: sql`(SELECT count(*)::int FROM projects p WHERE p.workspace_id = workspaces.id AND p.deleted_at IS NULL)`
291
+ }).from(workspaces).where(filter).orderBy(asc(workspaces.name), asc(workspaces.id)).limit(input.limit).offset(input.offset), database.db.select({ value: count() }).from(workspaces).where(filter)]);
292
+ const administrators = rows.length ? await database.db.select({
293
+ workspaceId: workspaceMembers.workspaceId,
294
+ id: user$1.id,
295
+ name: user$1.name,
296
+ email: user$1.email,
297
+ image: user$1.image
298
+ }).from(workspaceMembers).innerJoin(user$1, eq(user$1.id, workspaceMembers.userId)).where(and(eq(workspaceMembers.role, "admin"), inArray(workspaceMembers.workspaceId, rows.map((row) => row.id)))).orderBy(asc(user$1.name), asc(user$1.id)) : [];
299
+ return Response.json(json({
300
+ workspaces: rows.map((row) => ({
301
+ ...row,
302
+ administrators: administrators.filter((admin) => admin.workspaceId === row.id).map(({ id, name, email, image }) => ({
303
+ id,
304
+ name,
305
+ email,
306
+ image
307
+ }))
308
+ })),
309
+ total: total[0]?.value ?? 0
310
+ }));
311
+ });
236
312
  app.post("/api/v1/admin/users/:id/password", async (c) => mutate(c.req.raw, async () => {
237
313
  const input = localPasswordSchema.parse(await c.req.json()), userId = c.req.param("id");
238
314
  await auth.api.setUserPassword({
@@ -244,94 +320,13 @@ function createAccountAdministration(database, auth, config) {
244
320
  });
245
321
  await revokeAfterPasswordChange(c.req.raw.headers, userId);
246
322
  return Response.json({ ok: true });
247
- }));
248
- }
249
- };
250
- }
251
- //#endregion
252
- //#region src/access.ts
253
- const credentialPermissions = z.object({ kanvil: z.array(z.enum(scopes)) });
254
- const credentialMetadata = z.object({ projectIds: z.array(z.uuid()).max(1e3) });
255
- function createWriteLimiter(max, windowMs = 6e4) {
256
- const recent = /* @__PURE__ */ new Map();
257
- return { consume(key, now = Date.now()) {
258
- const hits = (recent.get(key) ?? []).filter((at) => at > now - windowMs);
259
- if (hits.length >= max) {
260
- const retryAfterSeconds = Math.max(1, Math.ceil(((hits[0] ?? now) + windowMs - now) / 1e3));
261
- fail(429, "rate_limited", "Too many changes in a short time; wait and retry", { retryAfterSeconds });
262
- }
263
- hits.push(now);
264
- recent.set(key, hits);
265
- if (recent.size > 1e4) {
266
- for (const [id, stamps] of recent) if (!stamps.some((at) => at > now - windowMs)) recent.delete(id);
323
+ }, true, c.req.param("id")));
267
324
  }
268
- } };
269
- }
270
- function requireSession(actor) {
271
- if (actor.machine) fail(403, "session_required", "Use a signed-in account for this action");
272
- return actor;
273
- }
274
- function requireInstanceAdmin(actor) {
275
- requireSession(actor);
276
- if (!actor.admin) fail(403, "forbidden", "Instance administrator access is required");
277
- return actor;
278
- }
279
- function storedJson(value) {
280
- if (typeof value !== "string") return value;
281
- try {
282
- return JSON.parse(value);
283
- } catch {
284
- return null;
285
- }
286
- }
287
- function createAccess(auth, domain, config, database) {
288
- async function credentialActor(key) {
289
- const permissions = credentialPermissions.safeParse(storedJson(key.permissions));
290
- const metadata = credentialMetadata.safeParse(storedJson(key.metadata));
291
- if (!permissions.success || !metadata.success) fail(403, "invalid_credential_scope", "This credential has no valid project permissions");
292
- return domain.actor(key.referenceId, {
293
- ownerId: key.id,
294
- scopes: permissions.data.kanvil,
295
- projectIds: metadata.data.projectIds
296
- });
297
- }
298
- async function authenticate(request) {
299
- const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
300
- if (token) {
301
- if (!token.startsWith("kv_")) fail(401, "unauthorized", "Use a Kanvil API credential");
302
- const result = await auth.api.verifyApiKey({ body: { key: token } });
303
- if (!result.valid || !result.key) fail(401, "unauthorized", "The API credential is invalid or expired");
304
- return credentialActor(result.key);
305
- }
306
- const session = await auth.api.getSession({ headers: request.headers });
307
- if (!session) fail(401, "unauthorized", "Sign in to continue");
308
- return domain.actor(session.user.id);
309
- }
310
- async function revalidate(request, identity) {
311
- if (!identity.machine) return authenticate(request);
312
- const key = (await database.db.select().from(apikey).where(eq(apikey.id, identity.ownerId)))[0];
313
- if (!key?.enabled || key.referenceId !== identity.id || key.expiresAt && key.expiresAt <= /* @__PURE__ */ new Date()) fail(401, "unauthorized", "The API credential is invalid or expired");
314
- return credentialActor(key);
315
- }
316
- function checkOrigin(request) {
317
- if ([
318
- "GET",
319
- "HEAD",
320
- "OPTIONS"
321
- ].includes(request.method)) return;
322
- const origin = request.headers.get("origin");
323
- if (origin && !config.trustedOrigins.includes(origin)) fail(403, "invalid_origin", "This origin is not allowed");
324
- if (request.headers.get("sec-fetch-site") === "cross-site" && !origin) fail(403, "invalid_origin", "Cross-site writes are not allowed");
325
- }
326
- return {
327
- authenticate,
328
- revalidate,
329
- checkOrigin
330
325
  };
331
326
  }
332
327
  //#endregion
333
328
  //#region src/version.ts
334
- const applicationVersion = "0.2.1";
329
+ const applicationVersion = "0.4.0";
335
330
  //#endregion
336
331
  //#region src/realtime.ts
337
332
  async function createRealtime(config) {
@@ -871,10 +866,10 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
871
866
  target: profilePhotos.userId,
872
867
  set: { objectId: id }
873
868
  });
874
- await tx.update(user).set({
869
+ await tx.update(user$1).set({
875
870
  image,
876
871
  updatedAt: /* @__PURE__ */ new Date()
877
- }).where(eq(user.id, actor.id));
872
+ }).where(eq(user$1.id, actor.id));
878
873
  });
879
874
  blobs.sync().catch(() => {});
880
875
  return Response.json({ image });
@@ -883,10 +878,10 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
883
878
  const actor = requireSession(c.get("actor"));
884
879
  await database.db.transaction(async (tx) => {
885
880
  await tx.delete(profilePhotos).where(eq(profilePhotos.userId, actor.id));
886
- await tx.update(user).set({
881
+ await tx.update(user$1).set({
887
882
  image: null,
888
883
  updatedAt: /* @__PURE__ */ new Date()
889
- }).where(eq(user.id, actor.id));
884
+ }).where(eq(user$1.id, actor.id));
890
885
  });
891
886
  return Response.json({ image: null });
892
887
  });
@@ -898,117 +893,2175 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
898
893
  });
899
894
  }
900
895
  //#endregion
901
- //#region src/push.ts
902
- const delay = (attempt) => Math.min(6e4 * 2 ** attempt, 36e5);
903
- async function deliverPushBatch(database, config, transport) {
904
- const lock = await database.pool.connect();
905
- let ownsLock = false;
906
- try {
907
- if (!(await lock.query("SELECT pg_try_advisory_lock(7361082) AS acquired")).rows[0]?.acquired) return;
908
- ownsLock = true;
909
- const jobs = await database.db.select({ id: pushDeliveries.id }).from(pushDeliveries).where(and(isNull(pushDeliveries.finishedAt), lte(pushDeliveries.availableAt, /* @__PURE__ */ new Date()))).orderBy(asc(pushDeliveries.availableAt)).limit(100);
910
- for (const pending of jobs) {
911
- const row = (await database.db.select({
912
- job: pushDeliveries,
913
- device: pushDevices,
914
- notification: notifications,
915
- event: events,
916
- preference: notificationPreferences,
917
- account: user,
918
- session,
919
- projectRole: projectRoleSQL(user.id, notifications.projectId),
920
- subscription: subscriptions,
921
- ticketId: tickets.id
922
- }).from(pushDeliveries).innerJoin(pushDevices, eq(pushDevices.id, pushDeliveries.deviceId)).innerJoin(notifications, eq(notifications.id, pushDeliveries.notificationId)).innerJoin(events, eq(events.id, notifications.eventId)).leftJoin(tickets, eq(tickets.id, events.ticketId)).innerJoin(user, eq(user.id, pushDevices.userId)).innerJoin(session, eq(session.id, pushDevices.sessionId)).leftJoin(notificationPreferences, eq(notificationPreferences.userId, user.id)).leftJoin(subscriptions, and(eq(subscriptions.ticketId, events.ticketId), eq(subscriptions.userId, user.id))).where(and(eq(pushDeliveries.id, pending.id), isNull(pushDeliveries.finishedAt))))[0];
923
- if (!row) continue;
924
- const { job, device, event } = row;
925
- const finish = async (error) => database.db.update(pushDeliveries).set({
926
- finishedAt: /* @__PURE__ */ new Date(),
927
- error
928
- }).where(eq(pushDeliveries.id, job.id));
929
- if (device.userId !== row.notification.userId || row.account.banned || row.session.expiresAt <= /* @__PURE__ */ new Date() || !row.preference?.push || !row.subscription && row.ticketId !== null || !row.projectRole && !hasAdminRole(row.account.role)) {
930
- await finish("access_revoked");
896
+ //#region src/adapters/github.ts
897
+ const sameSecret = (received, expected) => timingSafeEqual(createHash("sha256").update(received).digest(), createHash("sha256").update(expected).digest());
898
+ const hmacHex = (secret, body) => createHmac("sha256", secret).update(body).digest("hex");
899
+ const repository = z.object({
900
+ full_name: z.string(),
901
+ html_url: z.url({ protocol: /^https?$/ })
902
+ });
903
+ const reference = z.object({
904
+ ref: z.string().min(1),
905
+ ref_type: z.string(),
906
+ repository
907
+ });
908
+ const item$2 = {
909
+ number: z.number().int().positive(),
910
+ html_url: z.url({ protocol: /^https?$/ }),
911
+ title: z.string(),
912
+ state: z.enum(["open", "closed"]),
913
+ updated_at: z.iso.datetime({ offset: true })
914
+ };
915
+ const pullRequest = z.object({
916
+ action: z.string(),
917
+ pull_request: z.object({
918
+ ...item$2,
919
+ draft: z.boolean().default(false),
920
+ merged: z.boolean().nullish(),
921
+ merged_at: z.string().nullish(),
922
+ head: z.object({ ref: z.string() })
923
+ }),
924
+ changes: z.object({ title: z.object({ from: z.string() }).optional() }).optional(),
925
+ repository
926
+ });
927
+ const issueFields$1 = z.object({
928
+ ...item$2,
929
+ body: z.string().nullish(),
930
+ labels: z.array(z.object({ name: z.string() })).nullish(),
931
+ pull_request: z.unknown().optional()
932
+ });
933
+ const issue$1 = z.object({
934
+ action: z.string(),
935
+ issue: issueFields$1,
936
+ repository
937
+ });
938
+ const issueComment = z.object({
939
+ action: z.string(),
940
+ issue: issueFields$1,
941
+ comment: z.object({
942
+ id: z.union([z.number(), z.string()]),
943
+ body: z.string().nullish(),
944
+ user: z.object({ login: z.string() }).nullish(),
945
+ created_at: z.iso.datetime({ offset: true }).nullish()
946
+ }),
947
+ is_pull: z.boolean().optional(),
948
+ repository
949
+ });
950
+ const githubIssue = (found) => ({
951
+ number: found.number,
952
+ url: found.html_url,
953
+ title: found.title,
954
+ description: found.body ?? "",
955
+ open: found.state === "open",
956
+ labels: (found.labels ?? []).map((label) => label.name),
957
+ updatedAt: new Date(found.updated_at)
958
+ });
959
+ const issueActions = [
960
+ "opened",
961
+ "edited",
962
+ "closed",
963
+ "reopened",
964
+ "labeled",
965
+ "unlabeled",
966
+ "label_updated",
967
+ "label_cleared"
968
+ ];
969
+ function githubIssueEvent(event, payload) {
970
+ if (event !== "issues") return null;
971
+ const parsed = issue$1.parse(payload);
972
+ return parsed.issue.pull_request || !issueActions.includes(parsed.action) ? null : githubIssue(parsed.issue);
973
+ }
974
+ function githubComment(event, payload) {
975
+ if (event !== "issue_comment") return null;
976
+ const parsed = issueComment.parse(payload);
977
+ if (parsed.action !== "created" || parsed.issue.pull_request || parsed.is_pull) return null;
978
+ return {
979
+ id: String(parsed.comment.id),
980
+ issue: parsed.issue.number,
981
+ body: parsed.comment.body ?? "",
982
+ author: parsed.comment.user?.login ?? "",
983
+ createdAt: parsed.comment.created_at ? new Date(parsed.comment.created_at) : null
984
+ };
985
+ }
986
+ const readGitHubIssue = (body) => githubIssue(issueFields$1.parse(JSON.parse(body)));
987
+ const githubIssueBody = (changes) => JSON.stringify({
988
+ ...changes.title !== void 0 && { title: changes.title },
989
+ ...changes.description !== void 0 && { body: changes.description },
990
+ ...changes.open !== void 0 && { state: changes.open ? "open" : "closed" }
991
+ });
992
+ const pullRequestTriggers = {
993
+ opened: (draft) => draft ? "pull_request_draft" : "pull_request_opened",
994
+ reopened: (draft) => draft ? "pull_request_draft" : "pull_request_opened",
995
+ ready_for_review: () => "pull_request_opened",
996
+ converted_to_draft: () => "pull_request_draft"
997
+ };
998
+ const workInProgress = (title) => /^\s*(?:wip:|\[wip\])/i.test(title);
999
+ const githubNamed = (payload) => z.object({ repository: repository.optional() }).parse(payload).repository?.full_name ?? null;
1000
+ function githubChange(event, payload, { branchPath = "tree", titleDrafts = false } = {}) {
1001
+ if (event === "create" || event === "delete") {
1002
+ const parsed = reference.parse(payload);
1003
+ if (parsed.ref_type !== "branch") return null;
1004
+ const name = parsed.ref.replace(/^refs\/heads\//, "");
1005
+ const path = name.split("/").map(encodeURIComponent).join("/");
1006
+ return {
1007
+ kind: "branch",
1008
+ url: `${parsed.repository.html_url}/${branchPath}/${path}`,
1009
+ title: name,
1010
+ number: null,
1011
+ state: event === "create" ? "active" : "deleted",
1012
+ trigger: event === "create" ? "branch_created" : null,
1013
+ references: [name],
1014
+ occurredAt: null,
1015
+ creates: event === "create"
1016
+ };
1017
+ }
1018
+ if (event === "pull_request") {
1019
+ const { action, pull_request: pr, changes } = pullRequest.parse(payload);
1020
+ const merged = !!(pr.merged ?? pr.merged_at), draft = pr.draft || titleDrafts && workInProgress(pr.title);
1021
+ if (action !== "closed" && action !== "edited" && !pullRequestTriggers[action]) return null;
1022
+ const retitled = titleDrafts && action === "edited" && changes?.title && workInProgress(changes.title.from) !== draft;
1023
+ return {
1024
+ kind: "pull_request",
1025
+ url: pr.html_url,
1026
+ title: pr.title,
1027
+ number: pr.number,
1028
+ state: merged ? "merged" : pr.state === "closed" ? "closed" : draft ? "draft" : "open",
1029
+ trigger: action === "closed" ? merged ? "pull_request_merged" : "pull_request_closed" : retitled ? draft ? "pull_request_draft" : "pull_request_opened" : pullRequestTriggers[action]?.(draft) ?? null,
1030
+ references: [pr.head.ref, pr.title],
1031
+ occurredAt: new Date(pr.updated_at),
1032
+ creates: true
1033
+ };
1034
+ }
1035
+ if (event === "issues") {
1036
+ const { action, issue: found } = issue$1.parse(payload);
1037
+ if (!issueActions.includes(action)) return null;
1038
+ return {
1039
+ kind: "issue",
1040
+ url: found.html_url,
1041
+ title: found.title,
1042
+ number: found.number,
1043
+ state: found.state,
1044
+ trigger: null,
1045
+ references: [found.title],
1046
+ occurredAt: new Date(found.updated_at),
1047
+ creates: true
1048
+ };
1049
+ }
1050
+ return null;
1051
+ }
1052
+ const headers$2 = (token) => ({
1053
+ Accept: "application/vnd.github+json",
1054
+ Authorization: `Bearer ${token}`,
1055
+ "X-GitHub-Api-Version": "2022-11-28"
1056
+ });
1057
+ function createGitHubDriver(apiUrl) {
1058
+ const api = (path) => new URL(path, apiUrl.endsWith("/") ? apiUrl : `${apiUrl}/`).href;
1059
+ return {
1060
+ signed: (secret, received, body) => sameSecret(received.get("x-hub-signature-256") ?? "", `sha256=${hmacHex(secret, body)}`),
1061
+ read(received, body) {
1062
+ const id = received.get("x-github-delivery"), event = received.get("x-github-event");
1063
+ if (!id || !event) throw new Error("Missing GitHub delivery headers");
1064
+ const payload = JSON.parse(body);
1065
+ return {
1066
+ id,
1067
+ event,
1068
+ repository: githubNamed(payload),
1069
+ change: githubChange(event, payload),
1070
+ issue: githubIssueEvent(event, payload),
1071
+ comment: githubComment(event, payload)
1072
+ };
1073
+ },
1074
+ access: ({ repository: name }, token) => ({
1075
+ method: "GET",
1076
+ url: api(`repos/${name}`),
1077
+ headers: headers$2(token),
1078
+ body: ""
1079
+ }),
1080
+ comment: ({ repository: name }, token, { number }, text) => ({
1081
+ url: api(`repos/${name}/issues/${number}/comments`),
1082
+ headers: headers$2(token),
1083
+ body: JSON.stringify({ body: text }),
1084
+ read: true
1085
+ }),
1086
+ createIssue: ({ repository: name }, token, content) => ({
1087
+ url: api(`repos/${name}/issues`),
1088
+ headers: headers$2(token),
1089
+ body: githubIssueBody(content),
1090
+ read: true
1091
+ }),
1092
+ updateIssue: ({ repository: name }, token, number, changes) => ({
1093
+ method: "PATCH",
1094
+ url: api(`repos/${name}/issues/${number}`),
1095
+ headers: headers$2(token),
1096
+ body: githubIssueBody(changes),
1097
+ read: true
1098
+ }),
1099
+ issue: readGitHubIssue
1100
+ };
1101
+ }
1102
+ //#endregion
1103
+ //#region src/adapters/gitea.ts
1104
+ const instanceApi = ({ baseUrl }, path) => {
1105
+ if (!baseUrl) throw new DeliveryError("invalid_endpoint", false);
1106
+ return `${baseUrl}/${path}`;
1107
+ };
1108
+ const headers$1 = (token) => ({
1109
+ Accept: "application/json",
1110
+ Authorization: `token ${token}`
1111
+ });
1112
+ function createGiteaDriver() {
1113
+ return {
1114
+ signed: (secret, received, body) => sameSecret(received.get("x-gitea-signature") ?? "", hmacHex(secret, body)),
1115
+ read(received, body) {
1116
+ const id = received.get("x-gitea-delivery"), sent = received.get("x-gitea-event");
1117
+ if (!id || !sent) throw new Error("Missing Gitea delivery headers");
1118
+ const event = sent === "issue_label" ? "issues" : sent;
1119
+ const payload = JSON.parse(body);
1120
+ return {
1121
+ id,
1122
+ event,
1123
+ repository: githubNamed(payload),
1124
+ change: githubChange(event, payload, {
1125
+ branchPath: "src/branch",
1126
+ titleDrafts: true
1127
+ }),
1128
+ issue: githubIssueEvent(event, payload),
1129
+ comment: githubComment(event, payload)
1130
+ };
1131
+ },
1132
+ access: (target, token) => ({
1133
+ method: "GET",
1134
+ url: instanceApi(target, `api/v1/repos/${target.repository}`),
1135
+ headers: headers$1(token),
1136
+ body: ""
1137
+ }),
1138
+ comment: (target, token, { number }, text) => ({
1139
+ url: instanceApi(target, `api/v1/repos/${target.repository}/issues/${number}/comments`),
1140
+ headers: headers$1(token),
1141
+ body: JSON.stringify({ body: text }),
1142
+ read: true
1143
+ }),
1144
+ createIssue: (target, token, content) => ({
1145
+ url: instanceApi(target, `api/v1/repos/${target.repository}/issues`),
1146
+ headers: headers$1(token),
1147
+ body: githubIssueBody(content),
1148
+ read: true
1149
+ }),
1150
+ updateIssue: (target, token, number, changes) => ({
1151
+ method: "PATCH",
1152
+ url: instanceApi(target, `api/v1/repos/${target.repository}/issues/${number}`),
1153
+ headers: headers$1(token),
1154
+ body: githubIssueBody(changes),
1155
+ read: true
1156
+ }),
1157
+ issue: readGitHubIssue
1158
+ };
1159
+ }
1160
+ //#endregion
1161
+ //#region src/adapters/gitlab.ts
1162
+ const project$1 = z.object({
1163
+ path_with_namespace: z.string(),
1164
+ web_url: z.url({ protocol: /^https?$/ })
1165
+ });
1166
+ const push = z.object({
1167
+ ref: z.string(),
1168
+ before: z.string(),
1169
+ after: z.string(),
1170
+ project: project$1
1171
+ });
1172
+ const item$1 = {
1173
+ iid: z.number().int().positive(),
1174
+ url: z.url({ protocol: /^https?$/ }),
1175
+ title: z.string(),
1176
+ state: z.string(),
1177
+ action: z.string().default("update"),
1178
+ updated_at: z.string()
1179
+ };
1180
+ const mergeRequest = z.object({
1181
+ object_attributes: z.object({
1182
+ ...item$1,
1183
+ draft: z.boolean().optional(),
1184
+ work_in_progress: z.boolean().optional(),
1185
+ source_branch: z.string()
1186
+ }),
1187
+ changes: z.object({ draft: z.object({ current: z.boolean() }).optional() }).optional(),
1188
+ project: project$1
1189
+ });
1190
+ const issue = z.object({
1191
+ object_attributes: z.object({
1192
+ ...item$1,
1193
+ description: z.string().nullish()
1194
+ }),
1195
+ labels: z.array(z.object({ title: z.string() })).nullish(),
1196
+ project: project$1
1197
+ });
1198
+ const note = z.object({
1199
+ object_attributes: z.object({
1200
+ id: z.union([z.number(), z.string()]),
1201
+ note: z.string(),
1202
+ noteable_type: z.string(),
1203
+ created_at: z.string().nullish(),
1204
+ system: z.boolean().nullish()
1205
+ }),
1206
+ issue: z.object({ iid: z.number().int().positive() }).optional(),
1207
+ user: z.object({ username: z.string() }).nullish(),
1208
+ project: project$1
1209
+ });
1210
+ const apiIssue = z.object({
1211
+ iid: z.number().int().positive(),
1212
+ web_url: z.url({ protocol: /^https?$/ }),
1213
+ title: z.string(),
1214
+ description: z.string().nullish(),
1215
+ state: z.string(),
1216
+ labels: z.array(z.string()).nullish(),
1217
+ updated_at: z.string()
1218
+ });
1219
+ const absent = /^0+$/;
1220
+ function hostTime(value) {
1221
+ const time = new Date(value.replace(/^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) UTC$/, "$1T$2Z"));
1222
+ return Number.isNaN(time.getTime()) ? null : time;
1223
+ }
1224
+ const mergeRequestTriggers = {
1225
+ open: (draft) => draft ? "pull_request_draft" : "pull_request_opened",
1226
+ reopen: (draft) => draft ? "pull_request_draft" : "pull_request_opened",
1227
+ merge: () => "pull_request_merged",
1228
+ close: () => "pull_request_closed"
1229
+ };
1230
+ function issueOf(payload) {
1231
+ if (z.object({ object_kind: z.string() }).parse(payload).object_kind !== "issue") return null;
1232
+ const { object_attributes: found, labels } = issue.parse(payload);
1233
+ if (![
1234
+ "open",
1235
+ "update",
1236
+ "close",
1237
+ "reopen"
1238
+ ].includes(found.action)) return null;
1239
+ return {
1240
+ number: found.iid,
1241
+ url: found.url,
1242
+ title: found.title,
1243
+ description: found.description ?? "",
1244
+ open: found.state === "opened",
1245
+ labels: (labels ?? []).map((label) => label.title),
1246
+ updatedAt: hostTime(found.updated_at)
1247
+ };
1248
+ }
1249
+ function commentOf(payload) {
1250
+ if (z.object({ object_kind: z.string() }).parse(payload).object_kind !== "note") return null;
1251
+ const parsed = note.parse(payload), found = parsed.object_attributes;
1252
+ if (found.noteable_type !== "Issue" || found.system || !parsed.issue) return null;
1253
+ return {
1254
+ id: String(found.id),
1255
+ issue: parsed.issue.iid,
1256
+ body: found.note,
1257
+ author: parsed.user?.username ?? "",
1258
+ createdAt: found.created_at ? hostTime(found.created_at) : null
1259
+ };
1260
+ }
1261
+ function change(payload) {
1262
+ const kind = z.object({ object_kind: z.string() }).parse(payload).object_kind;
1263
+ if (kind === "push") {
1264
+ const parsed = push.parse(payload);
1265
+ const created = absent.test(parsed.before), deleted = absent.test(parsed.after);
1266
+ if (!parsed.ref.startsWith("refs/heads/") || created === deleted) return null;
1267
+ const name = parsed.ref.slice(11);
1268
+ return {
1269
+ kind: "branch",
1270
+ url: `${parsed.project.web_url}/-/tree/${name.split("/").map(encodeURIComponent).join("/")}`,
1271
+ title: name,
1272
+ number: null,
1273
+ state: created ? "active" : "deleted",
1274
+ trigger: created ? "branch_created" : null,
1275
+ references: [name],
1276
+ occurredAt: null,
1277
+ creates: created
1278
+ };
1279
+ }
1280
+ if (kind === "merge_request") {
1281
+ const { object_attributes: mr, changes } = mergeRequest.parse(payload);
1282
+ const draft = mr.draft ?? mr.work_in_progress ?? false;
1283
+ if (mr.action !== "update" && !mergeRequestTriggers[mr.action]) return null;
1284
+ return {
1285
+ kind: "pull_request",
1286
+ url: mr.url,
1287
+ title: mr.title,
1288
+ number: mr.iid,
1289
+ state: mr.state === "merged" || mr.state === "closed" ? mr.state : draft ? "draft" : "open",
1290
+ trigger: mr.action === "update" ? changes?.draft ? changes.draft.current ? "pull_request_draft" : "pull_request_opened" : null : mergeRequestTriggers[mr.action]?.(draft) ?? null,
1291
+ references: [mr.source_branch, mr.title],
1292
+ occurredAt: hostTime(mr.updated_at),
1293
+ creates: true
1294
+ };
1295
+ }
1296
+ if (kind === "issue") {
1297
+ const { object_attributes: found } = issue.parse(payload);
1298
+ if (![
1299
+ "open",
1300
+ "update",
1301
+ "close",
1302
+ "reopen"
1303
+ ].includes(found.action)) return null;
1304
+ return {
1305
+ kind: "issue",
1306
+ url: found.url,
1307
+ title: found.title,
1308
+ number: found.iid,
1309
+ state: found.state === "opened" ? "open" : found.state,
1310
+ trigger: null,
1311
+ references: [found.title],
1312
+ occurredAt: hostTime(found.updated_at),
1313
+ creates: true
1314
+ };
1315
+ }
1316
+ return null;
1317
+ }
1318
+ const headers = (token) => ({
1319
+ Accept: "application/json",
1320
+ "PRIVATE-TOKEN": token
1321
+ });
1322
+ const projectApi = (target, path = "") => instanceApi(target, `api/v4/projects/${encodeURIComponent(target.repository)}${path}`);
1323
+ function createGitLabDriver() {
1324
+ return {
1325
+ signed: (secret, received) => sameSecret(received.get("x-gitlab-token") ?? "", secret),
1326
+ read(received, body) {
1327
+ const id = received.get("idempotency-key") ?? received.get("x-gitlab-event-uuid"), event = received.get("x-gitlab-event");
1328
+ if (!id || !event) throw new Error("Missing GitLab delivery headers");
1329
+ const payload = JSON.parse(body);
1330
+ return {
1331
+ id,
1332
+ event,
1333
+ repository: z.object({ project: project$1.partial().optional() }).parse(payload).project?.path_with_namespace ?? null,
1334
+ change: change(payload),
1335
+ issue: issueOf(payload),
1336
+ comment: commentOf(payload)
1337
+ };
1338
+ },
1339
+ access: (target, token) => ({
1340
+ method: "GET",
1341
+ url: projectApi(target),
1342
+ headers: headers(token),
1343
+ body: ""
1344
+ }),
1345
+ comment: (target, token, { kind, number }, text) => ({
1346
+ url: projectApi(target, `/${kind === "pull_request" ? "merge_requests" : "issues"}/${number}/notes`),
1347
+ headers: headers(token),
1348
+ body: JSON.stringify({ body: text }),
1349
+ read: true
1350
+ }),
1351
+ createIssue: (target, token, content) => ({
1352
+ url: projectApi(target, "/issues"),
1353
+ headers: headers(token),
1354
+ body: JSON.stringify({
1355
+ title: content.title,
1356
+ description: content.description
1357
+ }),
1358
+ read: true
1359
+ }),
1360
+ updateIssue: (target, token, number, changes) => ({
1361
+ method: "PUT",
1362
+ url: projectApi(target, `/issues/${number}`),
1363
+ headers: headers(token),
1364
+ body: JSON.stringify({
1365
+ ...changes.title !== void 0 && { title: changes.title },
1366
+ ...changes.description !== void 0 && { description: changes.description },
1367
+ ...changes.open !== void 0 && { state_event: changes.open ? "reopen" : "close" }
1368
+ }),
1369
+ read: true
1370
+ }),
1371
+ issue(body) {
1372
+ const found = apiIssue.parse(JSON.parse(body));
1373
+ return {
1374
+ number: found.iid,
1375
+ url: found.web_url,
1376
+ title: found.title,
1377
+ description: found.description ?? "",
1378
+ open: found.state === "opened",
1379
+ labels: found.labels ?? [],
1380
+ updatedAt: hostTime(found.updated_at)
1381
+ };
1382
+ }
1383
+ };
1384
+ }
1385
+ //#endregion
1386
+ //#region src/adapters/source-bundle.ts
1387
+ const byStatus = (status) => status >= 200 && status < 300 ? "ok" : status === 401 || status === 403 ? "rejected" : status === 404 ? "not_found" : status === 429 ? "limited" : status >= 500 ? "failing" : "invalid";
1388
+ const attempts = 4;
1389
+ const limitWindows = {
1390
+ trello: 1e4,
1391
+ plane: 6e4,
1392
+ kaneo: 6e4
1393
+ };
1394
+ function delay(provider, response, attempt, outcome) {
1395
+ const header = response?.headers?.["retry-after"], value = Array.isArray(header) ? header[0] : header;
1396
+ const seconds = value === void 0 ? NaN : /^\d+$/.test(value) ? Number(value) : (Date.parse(value) - Date.now()) / 1e3;
1397
+ if (Number.isFinite(seconds)) return Math.min(Math.max(seconds, 0), 30) * 1e3;
1398
+ const window = outcome === "limited" ? limitWindows[provider] : void 0;
1399
+ return window ? window / 3 : 2 ** (attempt - 1) * 1e3;
1400
+ }
1401
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1402
+ const unreadable = (provider) => new ApiError(502, "source_unavailable", `${importProviderDefinitions[provider].label} sent a response Kanvil cannot read`);
1403
+ function readable(provider, schema, data) {
1404
+ const parsed = schema.safeParse(data);
1405
+ if (!parsed.success) throw unreadable(provider);
1406
+ return parsed.data;
1407
+ }
1408
+ async function readSource(http, provider, request, judge = byStatus, { retry = true } = {}) {
1409
+ const { label, project } = importProviderDefinitions[provider];
1410
+ for (let attempt = 1;; attempt++) {
1411
+ let response;
1412
+ try {
1413
+ response = await http({
1414
+ ...request,
1415
+ read: true
1416
+ });
1417
+ } catch (error) {
1418
+ if (!(error instanceof DeliveryError)) throw error;
1419
+ if (retry && error.code === "network_error" && attempt < attempts) {
1420
+ await wait(delay(provider, void 0, attempt));
931
1421
  continue;
932
1422
  }
933
- if (row.notification.createdAt.getTime() < Date.now() - 864e5) {
934
- await finish("expired");
935
- continue;
1423
+ throw new ApiError(502, "source_unavailable", `${label} could not be reached (${error.code})`);
1424
+ }
1425
+ let data;
1426
+ try {
1427
+ data = JSON.parse(response.body ?? "");
1428
+ } catch {
1429
+ data = void 0;
1430
+ }
1431
+ const outcome = judge(response.status, data);
1432
+ if (outcome === "ok") {
1433
+ if (data === void 0) throw unreadable(provider);
1434
+ return data;
1435
+ }
1436
+ if (retry && (outcome === "limited" || outcome === "failing") && attempt < attempts) {
1437
+ await wait(delay(provider, response, attempt, outcome));
1438
+ continue;
1439
+ }
1440
+ if (outcome === "rejected") throw new ApiError(422, "source_rejected", `${label} refused these credentials, or they cannot read this ${project.toLowerCase()}`);
1441
+ if (outcome === "not_found") throw new ApiError(404, "not_found", `${label} has no such ${project.toLowerCase()}`);
1442
+ if (outcome === "limited") throw new ApiError(429, "source_rate_limited", `${label} is limiting requests; try again later`);
1443
+ throw new ApiError(502, "source_unavailable", `${label} answered with an error (${response.status})`);
1444
+ }
1445
+ }
1446
+ const category = (name, fallback) => defaultWorkflow.find((state) => state.name.toLowerCase() === name.trim().toLowerCase())?.category ?? fallback;
1447
+ const dueAt = (date) => date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? `${date}T23:59:59.999Z` : null;
1448
+ const dueTime = (value) => value ? dueAt(value) ?? isoTime(value) : null;
1449
+ const startTime = (value) => !value ? null : /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : isoTime(value);
1450
+ const isoTime = (value) => new Date(value.replace(/([+-]\d{2})(\d{2})$/, "$1:$2")).toISOString();
1451
+ const kindOf = (names) => names.some((name) => name.trim().toLowerCase() === "bug") ? "bug" : "feature";
1452
+ function toneOf(color) {
1453
+ if (!color) return "gray";
1454
+ if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
1455
+ return color.toLowerCase().split(/[^a-z]+/).find(isPresetColor) ?? "gray";
1456
+ }
1457
+ function withChecklists(description, checklists) {
1458
+ const lists = checklists.filter((list) => list.items.length).map((list) => `### ${list.name.trim() || "Checklist"}\n\n${list.items.map((item) => `- [${item.done ? "x" : " "}] ${item.name.trim()}`).join("\n")}`);
1459
+ return [description.trim(), ...lists].filter(Boolean).join("\n\n");
1460
+ }
1461
+ const formerMember = (ref) => ({
1462
+ ref,
1463
+ name: "Former member",
1464
+ email: ""
1465
+ });
1466
+ function bundleBuilder(provider) {
1467
+ const { label, project } = importProviderDefinitions[provider];
1468
+ const people = /* @__PURE__ */ new Map(), labels = /* @__PURE__ */ new Map(), notes = /* @__PURE__ */ new Map();
1469
+ const tickets = [], relations = [], comments = [];
1470
+ const within = (size, limit, what) => {
1471
+ if (size >= limit) throw new ApiError(400, "validation_error", `This ${project.toLowerCase()} has more ${what} than an import holds (${limit})`);
1472
+ };
1473
+ const note = (content, reason, count = 1) => {
1474
+ const entry = notes.get(content) ?? {
1475
+ content,
1476
+ count: count === null ? null : 0,
1477
+ reason
1478
+ };
1479
+ if (entry.count !== null && count !== null) entry.count += count;
1480
+ notes.set(content, entry);
1481
+ };
1482
+ const clip = (value, max) => {
1483
+ if (value.length <= max) return value;
1484
+ note("shortened text", "Titles, descriptions, comments, or label names longer than Kanvil holds were cut at its limit.");
1485
+ return value.slice(0, max);
1486
+ };
1487
+ const person = (who) => {
1488
+ if (!who) return null;
1489
+ if (!people.has(who.ref)) {
1490
+ within(people.size, importLimits.members, "people");
1491
+ people.set(who.ref, who);
1492
+ }
1493
+ return who.ref;
1494
+ };
1495
+ return {
1496
+ note,
1497
+ clip,
1498
+ person,
1499
+ assignee(who) {
1500
+ if (who.length > 1) note("additional assignees", "A ticket has one assignee; items with several keep the first.", who.length - 1);
1501
+ return person(who[0]);
1502
+ },
1503
+ labels(items) {
1504
+ const names = [];
1505
+ for (const item of items) {
1506
+ const name = clip(item.name.trim(), 40);
1507
+ if (!name || names.includes(name)) continue;
1508
+ if (!labels.has(name)) {
1509
+ within(labels.size, importLimits.labels, "labels");
1510
+ labels.set(name, {
1511
+ name,
1512
+ color: toneOf(item.color)
1513
+ });
1514
+ }
1515
+ names.push(name);
936
1516
  }
937
- try {
938
- let result;
939
- if (job.receiptId) result = (await transport.getPushNotificationReceiptsAsync([job.receiptId]))[job.receiptId];
940
- else result = (await transport.sendPushNotificationsAsync([{
941
- to: device.token,
942
- title: "Kanvil",
943
- body: "A subscribed ticket has an update.",
944
- sound: "default",
945
- channelId: "tickets",
946
- ttl: 3600,
947
- data: {
948
- server: config.KANVIL_URL,
949
- projectId: event.projectId,
950
- ticketId: event.ticketId,
951
- notificationId: row.notification.id
1517
+ if (names.length > 30) note("labels past 30", "A ticket holds 30 labels; issues with more keep their first 30.");
1518
+ return names.slice(0, 30);
1519
+ },
1520
+ ticket(ticket) {
1521
+ within(tickets.length, importLimits.tickets, "issues");
1522
+ tickets.push(ticket);
1523
+ },
1524
+ relation(relation) {
1525
+ within(relations.length, importLimits.relations, "relationships");
1526
+ relations.push(relation);
1527
+ },
1528
+ comment(comment) {
1529
+ within(comments.length, importLimits.comments, "comments");
1530
+ comments.push(comment);
1531
+ },
1532
+ build(source, workflow) {
1533
+ if (!workflow.length) workflow.push({
1534
+ ref: "kanvil:todo",
1535
+ name: stateLabels.todo,
1536
+ category: "todo",
1537
+ color: "default"
1538
+ });
1539
+ const taken = /* @__PURE__ */ new Set();
1540
+ for (const state of workflow) {
1541
+ const base = state.name || "Untitled";
1542
+ let name = base;
1543
+ for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base.slice(0, 40 - ` ${n}`.length)} ${n}`;
1544
+ taken.add(name.toLowerCase());
1545
+ state.name = name;
1546
+ }
1547
+ return {
1548
+ format: ticketExportFormat,
1549
+ version: 1,
1550
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
1551
+ project: {
1552
+ ref: source.ref,
1553
+ key: source.key.slice(0, 20),
1554
+ name: clip(source.name.trim(), 120) || label,
1555
+ description: clip(source.description, 5e3)
1556
+ },
1557
+ completeness: { omitted: [...notes.values()] },
1558
+ members: [...people.values()],
1559
+ workflow,
1560
+ customFields: [],
1561
+ labels: [...labels.values()],
1562
+ tickets,
1563
+ relations,
1564
+ comments,
1565
+ evidence: [],
1566
+ resources: []
1567
+ };
1568
+ }
1569
+ };
1570
+ }
1571
+ const sourceTicket = (ticket) => ({
1572
+ acceptanceCriteria: "",
1573
+ difficulty: null,
1574
+ scopes: [],
1575
+ automationReady: false,
1576
+ holdReason: null,
1577
+ startAt: null,
1578
+ customFields: {},
1579
+ ...ticket
1580
+ });
1581
+ //#endregion
1582
+ //#region src/adapters/jira.ts
1583
+ const jiraProject = z.object({
1584
+ id: z.string(),
1585
+ key: z.string(),
1586
+ name: z.string(),
1587
+ description: z.string().nullish()
1588
+ });
1589
+ const jiraPerson = z.object({
1590
+ accountId: z.string().optional(),
1591
+ key: z.string().optional(),
1592
+ name: z.string().optional(),
1593
+ displayName: z.string().default(""),
1594
+ emailAddress: z.string().default("")
1595
+ }).nullish();
1596
+ const jiraComment = z.object({
1597
+ id: z.string(),
1598
+ body: z.string().nullish(),
1599
+ author: jiraPerson,
1600
+ created: z.string()
1601
+ });
1602
+ const commentPage = z.object({
1603
+ comments: z.array(jiraComment),
1604
+ total: z.number(),
1605
+ startAt: z.number().default(0)
1606
+ });
1607
+ const jiraIssue = z.object({
1608
+ id: z.string(),
1609
+ key: z.string(),
1610
+ fields: z.object({
1611
+ summary: z.string().default(""),
1612
+ description: z.string().nullish(),
1613
+ priority: z.object({ name: z.string() }).nullish(),
1614
+ labels: z.array(z.string()).default([]),
1615
+ assignee: jiraPerson,
1616
+ reporter: jiraPerson,
1617
+ parent: z.object({ id: z.string() }).nullish(),
1618
+ issuelinks: z.array(z.object({
1619
+ type: z.object({ name: z.string() }),
1620
+ outwardIssue: z.object({ id: z.string() }).optional()
1621
+ })).default([]),
1622
+ status: z.object({ id: z.string() }),
1623
+ issuetype: z.object({ name: z.string() }).nullish(),
1624
+ duedate: z.string().nullish(),
1625
+ created: z.string(),
1626
+ timeoriginalestimate: z.number().nullish(),
1627
+ fixVersions: z.array(z.unknown()).default([]),
1628
+ attachment: z.array(z.unknown()).default([]),
1629
+ comment: commentPage.optional()
1630
+ })
1631
+ });
1632
+ const statuses = z.array(z.object({ statuses: z.array(z.object({
1633
+ id: z.string(),
1634
+ name: z.string(),
1635
+ statusCategory: z.object({ key: z.string() }).optional()
1636
+ })) }));
1637
+ const jiraCategory = {
1638
+ new: "todo",
1639
+ indeterminate: "in_progress",
1640
+ done: "done"
1641
+ };
1642
+ const jiraPriority = {
1643
+ highest: "urgent",
1644
+ blocker: "urgent",
1645
+ critical: "urgent",
1646
+ high: "high",
1647
+ major: "high",
1648
+ medium: "medium",
1649
+ low: "low",
1650
+ minor: "low",
1651
+ lowest: "low",
1652
+ trivial: "low"
1653
+ };
1654
+ const jiraRelation = {
1655
+ blocks: "blocks",
1656
+ duplicate: "duplicate",
1657
+ cloners: "clones",
1658
+ "problem/incident": "causes"
1659
+ };
1660
+ const pageSize$2 = 50;
1661
+ const issueFields = [
1662
+ "summary",
1663
+ "description",
1664
+ "priority",
1665
+ "labels",
1666
+ "assignee",
1667
+ "reporter",
1668
+ "parent",
1669
+ "issuelinks",
1670
+ "status",
1671
+ "issuetype",
1672
+ "duedate",
1673
+ "created",
1674
+ "timeoriginalestimate",
1675
+ "fixVersions",
1676
+ "attachment"
1677
+ ];
1678
+ function createJiraReader(http) {
1679
+ const get = async (source, schema, path, query = {}) => {
1680
+ const url = new URL(`${source.baseUrl}/rest/api/2/${path}`);
1681
+ for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
1682
+ return readable("jira", schema, await readSource(http, "jira", {
1683
+ method: "GET",
1684
+ url: url.toString(),
1685
+ headers: {
1686
+ Accept: "application/json",
1687
+ Authorization: source.edition === "cloud" ? `Basic ${Buffer.from(`${source.email}:${source.token}`).toString("base64")}` : `Bearer ${source.token}`
1688
+ },
1689
+ body: ""
1690
+ }));
1691
+ };
1692
+ async function* issuePages(source, project, fields) {
1693
+ const common = {
1694
+ jql: `project = ${JSON.stringify(project)} ORDER BY created ASC`,
1695
+ fields: fields.join(","),
1696
+ maxResults: pageSize$2
1697
+ };
1698
+ if (source.edition === "cloud") for (let token;;) {
1699
+ const page = await get(source, z.object({
1700
+ issues: z.array(jiraIssue),
1701
+ nextPageToken: z.string().nullish(),
1702
+ isLast: z.boolean().optional()
1703
+ }), "search/jql", {
1704
+ ...common,
1705
+ ...token ? { nextPageToken: token } : {}
1706
+ });
1707
+ yield page.issues;
1708
+ if (page.isLast === true || !page.nextPageToken) return;
1709
+ token = page.nextPageToken;
1710
+ }
1711
+ for (let startAt = 0;;) {
1712
+ const page = await get(source, z.object({
1713
+ issues: z.array(jiraIssue),
1714
+ total: z.number()
1715
+ }), "search", {
1716
+ ...common,
1717
+ startAt
1718
+ });
1719
+ yield page.issues;
1720
+ startAt += page.issues.length;
1721
+ if (!page.issues.length || startAt >= page.total) return;
1722
+ }
1723
+ }
1724
+ return {
1725
+ async projects(source) {
1726
+ let found = [];
1727
+ if (source.edition === "data_center") found = await get(source, z.array(jiraProject), "project");
1728
+ else for (let startAt = 0;;) {
1729
+ const page = await get(source, z.object({
1730
+ values: z.array(jiraProject),
1731
+ isLast: z.boolean().default(true)
1732
+ }), "project/search", {
1733
+ startAt,
1734
+ maxResults: pageSize$2
1735
+ });
1736
+ found.push(...page.values);
1737
+ startAt += page.values.length;
1738
+ if (page.isLast || !page.values.length) break;
1739
+ }
1740
+ return found.map((item) => ({
1741
+ ref: item.id,
1742
+ key: item.key,
1743
+ name: item.name,
1744
+ group: ""
1745
+ }));
1746
+ },
1747
+ async bundle(source, projectRef, includeComments) {
1748
+ const project = await get(source, jiraProject, `project/${encodeURIComponent(projectRef)}`);
1749
+ const out = bundleBuilder("jira");
1750
+ const workflow = /* @__PURE__ */ new Map();
1751
+ for (const type of await get(source, statuses, `project/${project.id}/statuses`)) for (const status of type.statuses) workflow.set(status.id, {
1752
+ ref: status.id,
1753
+ name: out.clip(status.name.trim(), 40),
1754
+ category: category(status.name, jiraCategory[status.statusCategory?.key ?? ""] ?? "todo"),
1755
+ color: "default"
1756
+ });
1757
+ const person = (who) => {
1758
+ const ref = who && (who.accountId ?? who.key ?? who.name);
1759
+ return out.person(who && ref ? {
1760
+ ref,
1761
+ name: who.displayName || ref,
1762
+ email: who.emailAddress
1763
+ } : null);
1764
+ };
1765
+ const fields = includeComments ? [...issueFields, "comment"] : issueFields;
1766
+ for await (const page of issuePages(source, project.key, fields)) for (const { id, key, fields: issue } of page) {
1767
+ const labels = out.labels(issue.labels.map((name) => ({ name })));
1768
+ out.ticket(sourceTicket({
1769
+ ref: id,
1770
+ key,
1771
+ title: out.clip(issue.summary.trim(), 500) || key,
1772
+ description: out.clip(issue.description ?? "", 1e5),
1773
+ priority: jiraPriority[issue.priority?.name.toLowerCase() ?? ""] ?? "none",
1774
+ kind: kindOf([issue.issuetype?.name ?? "", ...labels]),
1775
+ labels,
1776
+ stateRef: issue.status.id,
1777
+ assigneeRef: person(issue.assignee),
1778
+ parentRef: issue.parent?.id ?? null,
1779
+ createdByRef: person(issue.reporter),
1780
+ createdAt: isoTime(issue.created),
1781
+ dueAt: dueAt(issue.duedate),
1782
+ archived: false
1783
+ }));
1784
+ for (const link of issue.issuelinks) if (link.outwardIssue) out.relation({
1785
+ sourceRef: id,
1786
+ targetRef: link.outwardIssue.id,
1787
+ type: jiraRelation[link.type.name.toLowerCase()] ?? "related"
1788
+ });
1789
+ if (issue.timeoriginalestimate != null) out.note("estimates", "Kanvil has no estimates; difficulty is left unset.");
1790
+ if (issue.fixVersions.length) out.note("fix versions", "Kanvil has no releases or milestones; the issues import without them.");
1791
+ if (issue.attachment.length) out.note("attachments", "Files stay in Jira; text that links to them keeps its addresses.", issue.attachment.length);
1792
+ if (!issue.comment) continue;
1793
+ const notes = [...issue.comment.comments];
1794
+ while (notes.length < issue.comment.total) {
1795
+ const more = await get(source, commentPage, `issue/${id}/comment`, {
1796
+ startAt: notes.length,
1797
+ maxResults: 100
1798
+ });
1799
+ if (!more.comments.length) break;
1800
+ notes.push(...more.comments);
1801
+ }
1802
+ for (const note of notes) if (note.body?.trim()) out.comment({
1803
+ ref: note.id,
1804
+ ticketRef: id,
1805
+ authorRef: person(note.author),
1806
+ body: out.clip(note.body, 5e4),
1807
+ createdAt: isoTime(note.created)
1808
+ });
1809
+ }
1810
+ if (!includeComments) out.note("comments", "Left out when the import was set up.", null);
1811
+ out.note("sprints and story points", "Jira keeps them in custom fields an import does not read.", null);
1812
+ out.note("Jira formatting", "Descriptions and comments keep Jira's wiki markup as plain text.", null);
1813
+ if (source.edition === "data_center") out.note("epic links", "Data Center keeps epic links in a custom field an import does not read; sub-tasks keep their parent.", null);
1814
+ return out.build({
1815
+ ref: project.id,
1816
+ key: project.key,
1817
+ name: project.name,
1818
+ description: project.description ?? ""
1819
+ }, [...workflow.values()]);
1820
+ }
1821
+ };
1822
+ }
1823
+ //#endregion
1824
+ //#region src/adapters/kaneo.ts
1825
+ const task = z.object({
1826
+ id: z.string(),
1827
+ number: z.number(),
1828
+ title: z.string(),
1829
+ description: z.string().nullish(),
1830
+ descriptionDeferred: z.boolean().optional(),
1831
+ status: z.string(),
1832
+ priority: z.string().nullish(),
1833
+ startDate: z.string().nullish(),
1834
+ dueDate: z.string().nullish(),
1835
+ createdAt: z.string(),
1836
+ userId: z.string().nullish(),
1837
+ assigneeName: z.string().nullish(),
1838
+ labels: z.array(z.object({
1839
+ name: z.string(),
1840
+ color: z.string().nullish()
1841
+ })).default([]),
1842
+ externalLinks: z.array(z.unknown()).default([])
1843
+ });
1844
+ const column = z.object({
1845
+ id: z.string(),
1846
+ name: z.string(),
1847
+ isFinal: z.boolean().default(false),
1848
+ position: z.number().default(0),
1849
+ tasks: z.array(task).default([])
1850
+ });
1851
+ const board$2 = z.object({
1852
+ data: z.object({
1853
+ id: z.string(),
1854
+ name: z.string(),
1855
+ slug: z.string(),
1856
+ description: z.string().nullish(),
1857
+ descriptionDeferred: z.boolean().optional(),
1858
+ workspaceId: z.string(),
1859
+ columns: z.array(column).default([]),
1860
+ archivedTasks: z.array(task).default([]),
1861
+ plannedTasks: z.array(task).default([])
1862
+ }),
1863
+ pagination: z.object({
1864
+ totalPages: z.number(),
1865
+ relatedTotalPages: z.number().default(1)
1866
+ })
1867
+ });
1868
+ const relation = z.object({
1869
+ id: z.string(),
1870
+ sourceTaskId: z.string(),
1871
+ targetTaskId: z.string(),
1872
+ relationType: z.string()
1873
+ });
1874
+ const comment$2 = z.object({
1875
+ id: z.string(),
1876
+ userId: z.string().nullish(),
1877
+ content: z.string().nullish(),
1878
+ createdAt: z.string(),
1879
+ user: z.object({ name: z.string().nullish() }).nullish()
1880
+ });
1881
+ const priorities$1 = [
1882
+ "urgent",
1883
+ "high",
1884
+ "medium",
1885
+ "low"
1886
+ ];
1887
+ const priorityOf$1 = (value) => priorities$1.find((priority) => priority === value) ?? "none";
1888
+ const plannedRef = "kaneo:planned";
1889
+ const pageSize$1 = 100;
1890
+ function createKaneoReader(http) {
1891
+ const get = async (source, schema, path, query = {}) => {
1892
+ const base = source.baseUrl.endsWith("/api") ? source.baseUrl : `${source.baseUrl}/api`;
1893
+ const url = new URL(`${base}/${path}`);
1894
+ for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
1895
+ return readable("kaneo", schema, await readSource(http, "kaneo", {
1896
+ method: "GET",
1897
+ url: url.toString(),
1898
+ headers: {
1899
+ Accept: "application/json",
1900
+ Authorization: `Bearer ${source.token}`
1901
+ },
1902
+ body: ""
1903
+ }));
1904
+ };
1905
+ return {
1906
+ async projects(source) {
1907
+ const workspaces = await get(source, z.array(z.object({
1908
+ id: z.string(),
1909
+ name: z.string()
1910
+ })), "workspace");
1911
+ const found = [];
1912
+ for (const workspace of workspaces) for (const project of await get(source, z.array(z.object({
1913
+ id: z.string(),
1914
+ name: z.string(),
1915
+ slug: z.string()
1916
+ })), "project", {
1917
+ workspaceId: workspace.id,
1918
+ includeArchived: "true"
1919
+ })) found.push({
1920
+ ref: project.id,
1921
+ key: project.slug.toUpperCase(),
1922
+ name: project.name,
1923
+ group: workspace.name
1924
+ });
1925
+ return found;
1926
+ },
1927
+ async bundle(source, projectId, includeComments) {
1928
+ const out = bundleBuilder("kaneo");
1929
+ const tasks = /* @__PURE__ */ new Map(), columns = /* @__PURE__ */ new Map();
1930
+ let project;
1931
+ for (let page = 1, pages = 1; page <= pages; page++) for (let related = 1, relatedPages = 1; related <= relatedPages; related++) {
1932
+ const found = await get(source, board$2, `task/tasks/${encodeURIComponent(projectId)}`, {
1933
+ page,
1934
+ limit: pageSize$1,
1935
+ relatedPage: related,
1936
+ sortBy: "number",
1937
+ sortOrder: "asc"
1938
+ });
1939
+ project ??= found.data;
1940
+ pages = found.pagination.totalPages;
1941
+ relatedPages = found.pagination.relatedTotalPages;
1942
+ for (const item of found.data.columns) if (!columns.has(item.id)) columns.set(item.id, item);
1943
+ for (const item of [
1944
+ ...found.data.columns.flatMap((list) => list.tasks),
1945
+ ...found.data.archivedTasks,
1946
+ ...found.data.plannedTasks
1947
+ ]) {
1948
+ const known = tasks.get(item.id);
1949
+ if (!known) tasks.set(item.id, item);
1950
+ else {
1951
+ const names = new Set(known.labels.map((label) => label.name));
1952
+ known.labels.push(...item.labels.filter((label) => !names.has(label.name)));
1953
+ known.externalLinks.push(...item.externalLinks);
952
1954
  }
953
- }]))[0];
954
- if (result?.status === "ok") {
955
- if (!job.receiptId && "id" in result) await database.db.update(pushDeliveries).set({
956
- receiptId: result.id,
957
- availableAt: new Date(Date.now() + 9e5),
958
- attempts: 0,
959
- error: null
960
- }).where(eq(pushDeliveries.id, job.id));
961
- else await finish(null);
962
- } else if (result?.status === "error" && result.details?.error !== "MessageRateExceeded") {
963
- if (result.details?.error === "DeviceNotRegistered") await database.db.delete(pushDevices).where(eq(pushDevices.id, device.id));
964
- else await finish(result.details?.error ?? "delivery_rejected");
965
- } else await retry(result ? "rate_limited" : "receipt_pending");
966
- } catch {
967
- await retry("transport_error");
1955
+ }
968
1956
  }
969
- async function retry(error) {
970
- await database.db.update(pushDeliveries).set({
971
- attempts: job.attempts + 1,
972
- error,
973
- availableAt: new Date(Date.now() + (job.receiptId ? 9e5 : delay(job.attempts))),
974
- finishedAt: job.attempts >= 4 && error !== "receipt_pending" ? /* @__PURE__ */ new Date() : null
975
- }).where(eq(pushDeliveries.id, job.id));
1957
+ if (!project) throw new ApiError(404, "not_found", "Kaneo has no such project");
1958
+ const lists = [...columns.values()].toSorted((a, b) => a.position - b.position);
1959
+ const workflow = lists.map((list) => ({
1960
+ ref: list.id,
1961
+ name: out.clip(list.name.trim(), 40) || list.id,
1962
+ category: category(list.name, list.isFinal ? "done" : "todo"),
1963
+ color: "default"
1964
+ }));
1965
+ if ([...tasks.values()].some((item) => item.status === "planned")) workflow.unshift({
1966
+ ref: plannedRef,
1967
+ name: "Planned",
1968
+ category: "backlog",
1969
+ color: "default"
1970
+ });
1971
+ const archivedRef = lists.findLast((list) => list.isFinal)?.id ?? lists.at(-1)?.id ?? plannedRef;
1972
+ const people = new Map((await get(source, z.array(z.object({
1973
+ id: z.string(),
1974
+ name: z.string().nullish(),
1975
+ email: z.string().nullish()
1976
+ })), `workspace/${encodeURIComponent(project.workspaceId)}/members`)).map((member) => [member.id, {
1977
+ ref: member.id,
1978
+ name: member.name || member.id,
1979
+ email: member.email ?? ""
1980
+ }]));
1981
+ const person = (id, name) => id ? people.get(id) ?? {
1982
+ ...formerMember(id),
1983
+ ...name ? { name } : {}
1984
+ } : null;
1985
+ const linked = /* @__PURE__ */ new Set(), parents = /* @__PURE__ */ new Map(), pending = [];
1986
+ for (const item of tasks.values()) {
1987
+ const key = `${project.slug.toUpperCase()}-${item.number}`;
1988
+ const description = item.descriptionDeferred ? (await get(source, z.object({ description: z.string().nullish() }), `task/${encodeURIComponent(item.id)}`)).description ?? "" : item.description ?? "";
1989
+ const labels = out.labels(item.labels);
1990
+ pending.push(sourceTicket({
1991
+ ref: item.id,
1992
+ key,
1993
+ title: out.clip(item.title.trim(), 500) || key,
1994
+ description: out.clip(description, 1e5),
1995
+ priority: priorityOf$1(item.priority),
1996
+ kind: kindOf(labels),
1997
+ labels,
1998
+ stateRef: item.status === "planned" ? plannedRef : item.status === "archived" || !columns.has(item.status) ? archivedRef : item.status,
1999
+ assigneeRef: out.person(person(item.userId, item.assigneeName)),
2000
+ parentRef: null,
2001
+ createdByRef: null,
2002
+ createdAt: isoTime(item.createdAt),
2003
+ startAt: startTime(item.startDate),
2004
+ dueAt: dueTime(item.dueDate),
2005
+ archived: item.status === "archived"
2006
+ }));
2007
+ if (item.externalLinks.length) out.note("external links", "Links to pull requests and issues stay in Kaneo.", item.externalLinks.length);
2008
+ for (const link of await get(source, z.array(relation), `task-relation/${encodeURIComponent(item.id)}`)) {
2009
+ if (linked.has(link.id)) continue;
2010
+ linked.add(link.id);
2011
+ if (link.relationType === "subtask") {
2012
+ if (parents.has(link.targetTaskId)) out.note("other parents", "A ticket has one parent; subtasks keep the first.");
2013
+ else parents.set(link.targetTaskId, link.sourceTaskId);
2014
+ } else out.relation({
2015
+ sourceRef: link.sourceTaskId,
2016
+ targetRef: link.targetTaskId,
2017
+ type: link.relationType === "blocks" ? "blocks" : "related"
2018
+ });
2019
+ }
2020
+ if (includeComments) {
2021
+ for (const note of await get(source, z.array(comment$2), `comment/${encodeURIComponent(item.id)}`)) if (note.content?.trim()) out.comment({
2022
+ ref: note.id,
2023
+ ticketRef: item.id,
2024
+ authorRef: out.person(person(note.userId, note.user?.name)),
2025
+ body: out.clip(note.content, 5e4),
2026
+ createdAt: isoTime(note.createdAt)
2027
+ });
2028
+ }
976
2029
  }
2030
+ for (const ticket of pending) out.ticket({
2031
+ ...ticket,
2032
+ parentRef: parents.get(ticket.ref) ?? null
2033
+ });
2034
+ if (!includeComments) out.note("comments", "Left out when the import was set up.", null);
2035
+ try {
2036
+ const fields = await get(source, z.array(z.unknown()), `custom-field/project/${encodeURIComponent(project.id)}`);
2037
+ if (fields.length) out.note("custom fields", "Custom fields and their values stay in Kaneo.", fields.length);
2038
+ } catch (error) {
2039
+ if (!(error instanceof ApiError && error.code === "not_found")) throw error;
2040
+ }
2041
+ out.note("time entries", "Kanvil has no time tracking; logged time stays in Kaneo.", null);
2042
+ return out.build({
2043
+ ref: project.id,
2044
+ key: project.slug.toUpperCase(),
2045
+ name: project.name,
2046
+ description: project.descriptionDeferred ? (await get(source, z.object({ description: z.string().nullish() }), `project/${encodeURIComponent(project.id)}`)).description ?? "" : project.description ?? ""
2047
+ }, workflow);
977
2048
  }
978
- } finally {
2049
+ };
2050
+ }
2051
+ //#endregion
2052
+ //#region src/adapters/linear.ts
2053
+ const pageInfo = z.object({
2054
+ hasNextPage: z.boolean(),
2055
+ endCursor: z.string().nullable()
2056
+ });
2057
+ const linearPerson = z.object({
2058
+ id: z.string(),
2059
+ name: z.string(),
2060
+ email: z.string().nullish()
2061
+ }).transform(({ id, name, email }) => ({
2062
+ ref: id,
2063
+ name,
2064
+ email: email ?? ""
2065
+ }));
2066
+ const ref = z.object({ id: z.string() }).nullable();
2067
+ const teams = z.object({ teams: z.object({
2068
+ nodes: z.array(z.object({
2069
+ id: z.string(),
2070
+ key: z.string(),
2071
+ name: z.string()
2072
+ })),
2073
+ pageInfo
2074
+ }) });
2075
+ const team = z.object({ team: z.object({
2076
+ id: z.string(),
2077
+ key: z.string(),
2078
+ name: z.string(),
2079
+ description: z.string().nullable(),
2080
+ states: z.object({ nodes: z.array(z.object({
2081
+ id: z.string(),
2082
+ name: z.string(),
2083
+ type: z.string(),
2084
+ color: z.string()
2085
+ })) })
2086
+ }) });
2087
+ const issues = z.object({ team: z.object({ issues: z.object({
2088
+ nodes: z.array(z.object({
2089
+ id: z.string(),
2090
+ identifier: z.string(),
2091
+ title: z.string(),
2092
+ description: z.string().nullable(),
2093
+ priority: z.number(),
2094
+ estimate: z.number().nullable(),
2095
+ dueDate: z.string().nullable(),
2096
+ createdAt: z.string(),
2097
+ archivedAt: z.string().nullable(),
2098
+ state: z.object({ id: z.string() }),
2099
+ parent: ref,
2100
+ cycle: ref,
2101
+ projectMilestone: ref,
2102
+ project: ref,
2103
+ assignee: linearPerson.nullable(),
2104
+ creator: linearPerson.nullable(),
2105
+ labels: z.object({
2106
+ nodes: z.array(z.object({
2107
+ name: z.string(),
2108
+ color: z.string()
2109
+ })),
2110
+ pageInfo: pageInfo.pick({ hasNextPage: true })
2111
+ }),
2112
+ relations: z.object({
2113
+ nodes: z.array(z.object({
2114
+ type: z.string(),
2115
+ relatedIssue: z.object({ id: z.string() })
2116
+ })),
2117
+ pageInfo: pageInfo.pick({ hasNextPage: true })
2118
+ })
2119
+ })),
2120
+ pageInfo
2121
+ }) }) });
2122
+ const comments = z.object({ comments: z.object({
2123
+ nodes: z.array(z.object({
2124
+ id: z.string(),
2125
+ body: z.string(),
2126
+ createdAt: z.string(),
2127
+ user: linearPerson.nullable(),
2128
+ issue: ref
2129
+ })),
2130
+ pageInfo
2131
+ }) });
2132
+ const teamsQuery = `query Teams($after: String) {
2133
+ teams(first: 100, after: $after) { nodes { id key name } pageInfo { hasNextPage endCursor } }
2134
+ }`;
2135
+ const teamQuery = `query Team($team: String!) {
2136
+ team(id: $team) { id key name description states(first: 100) { nodes { id name type color } } }
2137
+ }`;
2138
+ const issuesQuery = `query Issues($team: String!, $after: String) {
2139
+ team(id: $team) {
2140
+ issues(first: 50, after: $after, includeArchived: true, orderBy: createdAt) {
2141
+ nodes {
2142
+ id identifier title description priority estimate dueDate createdAt archivedAt
2143
+ state { id } parent { id } cycle { id } projectMilestone { id } project { id }
2144
+ assignee { id name email } creator { id name email }
2145
+ labels(first: 50) { nodes { name color } pageInfo { hasNextPage } }
2146
+ relations(first: 50) { nodes { type relatedIssue { id } } pageInfo { hasNextPage } }
2147
+ }
2148
+ pageInfo { hasNextPage endCursor }
2149
+ }
2150
+ }
2151
+ }`;
2152
+ const commentsQuery = `query Comments($team: ID!, $after: String) {
2153
+ comments(first: 100, after: $after, includeArchived: true, filter: { issue: { team: { id: { eq: $team } } } }) {
2154
+ nodes { id body createdAt user { id name email } issue { id } }
2155
+ pageInfo { hasNextPage endCursor }
2156
+ }
2157
+ }`;
2158
+ const graphErrors = z.object({ errors: z.array(z.object({
2159
+ message: z.string().default(""),
2160
+ extensions: z.object({ code: z.string().optional() }).optional()
2161
+ })).optional() }).catch({});
2162
+ function judge$1(status, data) {
2163
+ const errors = graphErrors.parse(data).errors ?? [];
2164
+ const codes = errors.map((error) => error.extensions?.code);
2165
+ if (codes.includes("RATELIMITED")) return "limited";
2166
+ if (codes.includes("AUTHENTICATION_ERROR") || codes.includes("FORBIDDEN")) return "rejected";
2167
+ if (errors.some((error) => /not found/i.test(error.message))) return "not_found";
2168
+ if (errors.length) return status >= 200 && status < 300 ? "invalid" : byStatus(status);
2169
+ return byStatus(status);
2170
+ }
2171
+ const linearCategory = {
2172
+ triage: "backlog",
2173
+ backlog: "backlog",
2174
+ unstarted: "todo",
2175
+ started: "in_progress",
2176
+ completed: "done",
2177
+ canceled: "cancelled"
2178
+ };
2179
+ const linearPriority = [
2180
+ "none",
2181
+ "urgent",
2182
+ "high",
2183
+ "medium",
2184
+ "low"
2185
+ ];
2186
+ const linearRelation = {
2187
+ blocks: "blocks",
2188
+ duplicate: "duplicate",
2189
+ related: "related",
2190
+ similar: "related"
2191
+ };
2192
+ const pages$1 = async (read) => {
2193
+ const all = [];
2194
+ for (let after = null;;) {
2195
+ const page = await read(after);
2196
+ all.push(...page.nodes);
2197
+ if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) return all;
2198
+ after = page.pageInfo.endCursor;
2199
+ }
2200
+ };
2201
+ function createLinearReader(http, url) {
2202
+ const query = async (token, schema, text, variables) => {
2203
+ const data = await readSource(http, "linear", {
2204
+ method: "POST",
2205
+ url,
2206
+ headers: { Authorization: token },
2207
+ body: JSON.stringify({
2208
+ query: text,
2209
+ variables
2210
+ })
2211
+ }, judge$1);
2212
+ return readable("linear", schema, readable("linear", z.object({ data: z.unknown() }), data).data);
2213
+ };
2214
+ return {
2215
+ async projects(token) {
2216
+ return (await pages$1(async (after) => (await query(token, teams, teamsQuery, { after })).teams)).map((item) => ({
2217
+ ref: item.id,
2218
+ key: item.key,
2219
+ name: item.name,
2220
+ group: ""
2221
+ }));
2222
+ },
2223
+ async bundle(token, teamId, includeComments) {
2224
+ const source = (await query(token, team, teamQuery, { team: teamId })).team;
2225
+ const out = bundleBuilder("linear");
2226
+ const workflow = source.states.nodes.map((state) => ({
2227
+ ref: state.id,
2228
+ name: out.clip(state.name.trim(), 40),
2229
+ category: category(state.name, linearCategory[state.type] ?? "todo"),
2230
+ color: toneOf(state.color)
2231
+ }));
2232
+ const read = await pages$1(async (after) => (await query(token, issues, issuesQuery, {
2233
+ team: source.id,
2234
+ after
2235
+ })).team.issues);
2236
+ for (const issue of read) {
2237
+ const labels = out.labels(issue.labels.nodes);
2238
+ out.ticket(sourceTicket({
2239
+ ref: issue.id,
2240
+ key: issue.identifier,
2241
+ title: out.clip(issue.title.trim(), 500) || issue.identifier,
2242
+ description: out.clip(issue.description ?? "", 1e5),
2243
+ priority: linearPriority[issue.priority] ?? "none",
2244
+ kind: kindOf(labels),
2245
+ labels,
2246
+ stateRef: issue.state.id,
2247
+ assigneeRef: out.person(issue.assignee),
2248
+ parentRef: issue.parent?.id ?? null,
2249
+ createdByRef: out.person(issue.creator),
2250
+ createdAt: isoTime(issue.createdAt),
2251
+ dueAt: dueAt(issue.dueDate),
2252
+ archived: issue.archivedAt !== null
2253
+ }));
2254
+ for (const relation of issue.relations.nodes) out.relation({
2255
+ sourceRef: issue.id,
2256
+ targetRef: relation.relatedIssue.id,
2257
+ type: linearRelation[relation.type] ?? "related"
2258
+ });
2259
+ if (issue.labels.pageInfo.hasNextPage || issue.relations.pageInfo.hasNextPage) out.note("labels or relationships past 50", "Kanvil reads the first 50 labels and relationships of each Linear issue.");
2260
+ if (issue.cycle) out.note("cycles", "Kanvil has no cycles; issues keep their state and dates.");
2261
+ if (issue.estimate !== null) out.note("estimates", "Kanvil has no estimates; difficulty is left unset.");
2262
+ if (issue.projectMilestone) out.note("milestones", "Kanvil has no milestones; the issues import without them.");
2263
+ if (issue.project) out.note("Linear projects", "Project membership is not carried over; issues import into one project.");
2264
+ }
2265
+ if (includeComments) {
2266
+ const notes = await pages$1(async (after) => (await query(token, comments, commentsQuery, {
2267
+ team: source.id,
2268
+ after
2269
+ })).comments);
2270
+ for (const note of notes) if (note.issue && note.body.trim()) out.comment({
2271
+ ref: note.id,
2272
+ ticketRef: note.issue.id,
2273
+ authorRef: out.person(note.user),
2274
+ body: out.clip(note.body, 5e4),
2275
+ createdAt: isoTime(note.createdAt)
2276
+ });
2277
+ } else out.note("comments", "Left out when the import was set up.", null);
2278
+ out.note("attachments", "Files and linked resources stay in Linear.", null);
2279
+ return out.build({
2280
+ ref: source.id,
2281
+ key: source.key,
2282
+ name: source.name,
2283
+ description: source.description ?? ""
2284
+ }, workflow);
2285
+ }
2286
+ };
2287
+ }
2288
+ //#endregion
2289
+ //#region src/adapters/plane.ts
2290
+ const perPage = 100;
2291
+ const listOf = (item) => z.union([z.array(item).transform((results) => ({
2292
+ results,
2293
+ next_cursor: null,
2294
+ more: false
2295
+ })), z.object({
2296
+ results: z.array(item),
2297
+ next_cursor: z.string().nullish(),
2298
+ next_page_results: z.boolean().default(false)
2299
+ }).transform(({ results, next_cursor, next_page_results }) => ({
2300
+ results,
2301
+ next_cursor,
2302
+ more: next_page_results
2303
+ }))]);
2304
+ const project = z.object({
2305
+ id: z.string(),
2306
+ identifier: z.string(),
2307
+ name: z.string(),
2308
+ description: z.string().nullish()
2309
+ });
2310
+ const state = z.object({
2311
+ id: z.string(),
2312
+ name: z.string(),
2313
+ color: z.string().nullish(),
2314
+ group: z.string()
2315
+ });
2316
+ const label$1 = z.object({
2317
+ id: z.string(),
2318
+ name: z.string(),
2319
+ color: z.string().nullish()
2320
+ });
2321
+ const member$1 = z.object({
2322
+ id: z.string(),
2323
+ display_name: z.string().nullish(),
2324
+ first_name: z.string().nullish(),
2325
+ last_name: z.string().nullish(),
2326
+ email: z.string().nullish()
2327
+ });
2328
+ const named = z.union([z.string(), z.object({ id: z.string() }).transform(({ id }) => id)]);
2329
+ const item = z.object({
2330
+ id: z.string(),
2331
+ sequence_id: z.number(),
2332
+ name: z.string(),
2333
+ description_html: z.string().nullish(),
2334
+ priority: z.string().nullish(),
2335
+ state: named.nullish(),
2336
+ parent: named.nullish(),
2337
+ assignees: z.array(named).default([]),
2338
+ labels: z.array(named).default([]),
2339
+ start_date: z.string().nullish(),
2340
+ target_date: z.string().nullish(),
2341
+ created_at: z.string(),
2342
+ created_by: named.nullish(),
2343
+ estimate_point: named.nullish(),
2344
+ cycle_id: z.string().nullish(),
2345
+ attachment_count: z.number().default(0),
2346
+ link_count: z.number().default(0)
2347
+ });
2348
+ const comment$1 = z.object({
2349
+ id: z.string(),
2350
+ comment_html: z.string().nullish(),
2351
+ actor: named.nullish(),
2352
+ created_by: named.nullish(),
2353
+ created_at: z.string()
2354
+ });
2355
+ const linked = z.array(z.object({ issue_id: z.string() })).default([]);
2356
+ const relations$1 = z.object({
2357
+ blocking: linked,
2358
+ duplicate: linked,
2359
+ relates_to: linked,
2360
+ start_before: linked,
2361
+ start_after: linked,
2362
+ finish_before: linked,
2363
+ finish_after: linked
2364
+ });
2365
+ const planeCategory = {
2366
+ triage: "backlog",
2367
+ backlog: "backlog",
2368
+ unstarted: "todo",
2369
+ started: "in_progress",
2370
+ completed: "done",
2371
+ cancelled: "cancelled"
2372
+ };
2373
+ const priorities = [
2374
+ "urgent",
2375
+ "high",
2376
+ "medium",
2377
+ "low"
2378
+ ];
2379
+ const priorityOf = (value) => priorities.find((priority) => priority === value) ?? "none";
2380
+ const markdown = new TurndownService({
2381
+ headingStyle: "atx",
2382
+ codeBlockStyle: "fenced",
2383
+ bulletListMarker: "-"
2384
+ });
2385
+ const text = (html) => html ? markdown.turndown(html).trim() : "";
2386
+ const notFound = (error) => error instanceof ApiError && error.code === "not_found";
2387
+ function createPlaneReader(http) {
2388
+ const get = async (source, schema, path, query = {}) => {
2389
+ const url = new URL(`${source.baseUrl}/api/v1/workspaces/${encodeURIComponent(source.workspace)}/${path}`);
2390
+ for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
2391
+ return readable("plane", schema, await readSource(http, "plane", {
2392
+ method: "GET",
2393
+ url: url.toString(),
2394
+ headers: {
2395
+ Accept: "application/json",
2396
+ "X-API-Key": source.token
2397
+ },
2398
+ body: ""
2399
+ }));
2400
+ };
2401
+ const all = async (source, schema, path) => {
2402
+ const found = [];
2403
+ for (let cursor = null;;) {
2404
+ const page = await get(source, listOf(schema), path, {
2405
+ per_page: perPage,
2406
+ ...cursor ? { cursor } : {}
2407
+ });
2408
+ found.push(...page.results);
2409
+ if (!page.more || !page.next_cursor || page.next_cursor === cursor) return found;
2410
+ cursor = page.next_cursor;
2411
+ }
2412
+ };
2413
+ return {
2414
+ async projects(source) {
2415
+ return (await all(source, project, "projects/")).map((found) => ({
2416
+ ref: found.id,
2417
+ key: found.identifier,
2418
+ name: found.name,
2419
+ group: ""
2420
+ }));
2421
+ },
2422
+ async bundle(source, projectId, includeComments) {
2423
+ const found = await get(source, project, `projects/${encodeURIComponent(projectId)}/`);
2424
+ const path = `projects/${encodeURIComponent(found.id)}`;
2425
+ const out = bundleBuilder("plane");
2426
+ const workflow = (await all(source, state, `${path}/states/`)).map((entry) => ({
2427
+ ref: entry.id,
2428
+ name: out.clip(entry.name.trim(), 40),
2429
+ category: category(entry.name, planeCategory[entry.group] ?? "todo"),
2430
+ color: toneOf(entry.color)
2431
+ }));
2432
+ const labels = new Map((await all(source, label$1, `${path}/labels/`)).map((entry) => [entry.id, entry]));
2433
+ const people = new Map((await all(source, member$1, `${path}/members/`)).map((entry) => [entry.id, {
2434
+ ref: entry.id,
2435
+ name: entry.display_name || [entry.first_name, entry.last_name].filter(Boolean).join(" ") || entry.id,
2436
+ email: entry.email ?? ""
2437
+ }]));
2438
+ const person = (id) => id ? people.get(id) ?? formerMember(id) : null;
2439
+ let items, kind = "work-items";
2440
+ try {
2441
+ items = await all(source, item, `${path}/work-items/`);
2442
+ } catch (error) {
2443
+ if (!notFound(error)) throw error;
2444
+ kind = "issues";
2445
+ items = await all(source, item, `${path}/issues/`);
2446
+ }
2447
+ const key = (sequence) => `${found.identifier}-${sequence}`;
2448
+ const related = /* @__PURE__ */ new Set();
2449
+ let readsRelations = kind === "work-items";
2450
+ for (const work of items) {
2451
+ const names = out.labels(work.labels.flatMap((id) => {
2452
+ const tag = labels.get(id);
2453
+ return tag ? [{
2454
+ name: tag.name,
2455
+ color: tag.color
2456
+ }] : [];
2457
+ }));
2458
+ out.ticket(sourceTicket({
2459
+ ref: work.id,
2460
+ key: key(work.sequence_id),
2461
+ title: out.clip(work.name.trim(), 500) || key(work.sequence_id),
2462
+ description: out.clip(text(work.description_html), 1e5),
2463
+ priority: priorityOf(work.priority),
2464
+ kind: kindOf(names),
2465
+ labels: names,
2466
+ stateRef: work.state ?? workflow[0]?.ref ?? "",
2467
+ assigneeRef: out.assignee(work.assignees.flatMap((id) => person(id) ?? [])),
2468
+ parentRef: work.parent ?? null,
2469
+ createdByRef: out.person(person(work.created_by)),
2470
+ createdAt: isoTime(work.created_at),
2471
+ startAt: startTime(work.start_date),
2472
+ dueAt: dueTime(work.target_date),
2473
+ archived: false
2474
+ }));
2475
+ if (work.estimate_point) out.note("estimates", "Kanvil has no estimates; difficulty is left unset.");
2476
+ if (work.cycle_id) out.note("cycles", "Kanvil has no cycles; work items keep their state and dates.");
2477
+ if (work.attachment_count) out.note("attachments", "Files stay in Plane.", work.attachment_count);
2478
+ if (work.link_count) out.note("links", "Work item links stay in Plane.", work.link_count);
2479
+ if (readsRelations) try {
2480
+ const links = await get(source, relations$1, `${path}/work-items/${encodeURIComponent(work.id)}/relations/`);
2481
+ const add = (type, target, mutual) => {
2482
+ const pair = mutual ? [work.id, target].toSorted().join(":") : `${work.id}:${target}`;
2483
+ if (related.has(`${type}:${pair}`)) return;
2484
+ related.add(`${type}:${pair}`);
2485
+ out.relation({
2486
+ sourceRef: work.id,
2487
+ targetRef: target,
2488
+ type
2489
+ });
2490
+ };
2491
+ for (const other of links.blocking) add("blocks", other.issue_id, false);
2492
+ for (const other of links.duplicate) add("duplicate", other.issue_id, true);
2493
+ for (const other of links.relates_to) add("related", other.issue_id, true);
2494
+ const dated = links.start_before.length + links.start_after.length + links.finish_before.length + links.finish_after.length;
2495
+ if (dated) out.note("date dependencies", "Kanvil has no start or finish dependencies; the work items keep their dates.", dated);
2496
+ } catch (error) {
2497
+ if (!notFound(error)) throw error;
2498
+ readsRelations = false;
2499
+ }
2500
+ if (includeComments) for (const note of (await all(source, comment$1, `${path}/${kind}/${encodeURIComponent(work.id)}/comments/`)).toSorted((a, b) => a.created_at.localeCompare(b.created_at))) {
2501
+ const body = text(note.comment_html);
2502
+ if (body) out.comment({
2503
+ ref: note.id,
2504
+ ticketRef: work.id,
2505
+ authorRef: out.person(person(note.actor ?? note.created_by)),
2506
+ body: out.clip(body, 5e4),
2507
+ createdAt: isoTime(note.created_at)
2508
+ });
2509
+ }
2510
+ }
2511
+ if (!readsRelations) out.note("relations", "This Plane version does not list relations through its API.", null);
2512
+ if (!includeComments) out.note("comments", "Left out when the import was set up.", null);
2513
+ out.note("archived, draft, and intake work items", "Plane's API does not list them; restore or publish them in Plane first.", null);
2514
+ try {
2515
+ const modules = await get(source, z.object({ total_count: z.number().default(0) }).catch({ total_count: 0 }), `${path}/modules/`, { per_page: 1 });
2516
+ if (modules.total_count) out.note("modules", "Kanvil has no modules; the work items import without them.", modules.total_count);
2517
+ } catch (error) {
2518
+ if (!notFound(error)) throw error;
2519
+ }
2520
+ return out.build({
2521
+ ref: found.id,
2522
+ key: found.identifier,
2523
+ name: found.name,
2524
+ description: found.description ?? ""
2525
+ }, workflow);
2526
+ }
2527
+ };
2528
+ }
2529
+ //#endregion
2530
+ //#region src/adapters/planka.ts
2531
+ const user = z.object({
2532
+ id: z.string(),
2533
+ name: z.string().nullish(),
2534
+ username: z.string().nullish(),
2535
+ email: z.string().nullish()
2536
+ });
2537
+ const card$1 = z.object({
2538
+ id: z.string(),
2539
+ name: z.string(),
2540
+ description: z.string().nullish(),
2541
+ dueDate: z.string().nullish(),
2542
+ listId: z.string(),
2543
+ prevListId: z.string().nullish(),
2544
+ creatorUserId: z.string().nullish(),
2545
+ createdAt: z.string(),
2546
+ listChangedAt: z.string().nullish(),
2547
+ stopwatch: z.unknown().nullish(),
2548
+ commentsTotal: z.number().optional()
2549
+ });
2550
+ const included = z.object({
2551
+ users: z.array(user).default([]),
2552
+ cards: z.array(card$1).default([]),
2553
+ cardLabels: z.array(z.object({
2554
+ cardId: z.string(),
2555
+ labelId: z.string()
2556
+ })).default([]),
2557
+ cardMemberships: z.array(z.object({
2558
+ cardId: z.string(),
2559
+ userId: z.string()
2560
+ })).default([]),
2561
+ taskLists: z.array(z.object({
2562
+ id: z.string(),
2563
+ cardId: z.string(),
2564
+ name: z.string().nullish(),
2565
+ position: z.number().nullish()
2566
+ })).optional(),
2567
+ tasks: z.array(z.object({
2568
+ cardId: z.string().nullish(),
2569
+ taskListId: z.string().nullish(),
2570
+ name: z.string(),
2571
+ isCompleted: z.boolean().default(false),
2572
+ position: z.number().nullish()
2573
+ })).default([]),
2574
+ attachments: z.array(z.object({ cardId: z.string() })).default([]),
2575
+ customFieldValues: z.array(z.object({ cardId: z.string() })).default([])
2576
+ });
2577
+ const board$1 = z.object({
2578
+ item: z.object({
2579
+ id: z.string(),
2580
+ name: z.string()
2581
+ }),
2582
+ included: included.extend({
2583
+ lists: z.array(z.object({
2584
+ id: z.string(),
2585
+ name: z.string().nullish(),
2586
+ type: z.string().nullish(),
2587
+ position: z.number().nullish()
2588
+ })).default([]),
2589
+ labels: z.array(z.object({
2590
+ id: z.string(),
2591
+ name: z.string().nullish(),
2592
+ color: z.string().nullish()
2593
+ })).default([])
2594
+ })
2595
+ });
2596
+ const cardPage = z.object({
2597
+ items: z.array(card$1),
2598
+ included: included.omit({ cards: true })
2599
+ });
2600
+ const notes = z.object({
2601
+ items: z.array(z.object({
2602
+ id: z.string(),
2603
+ type: z.string().optional(),
2604
+ text: z.string().optional(),
2605
+ data: z.object({ text: z.string().optional() }).nullish(),
2606
+ userId: z.string().nullish(),
2607
+ createdAt: z.string()
2608
+ })),
2609
+ included: z.object({ users: z.array(user).default([]) }).default({ users: [] })
2610
+ });
2611
+ const pageSize = 50;
2612
+ const byPosition$1 = (a, b) => (a.position ?? Number.MAX_SAFE_INTEGER) - (b.position ?? Number.MAX_SAFE_INTEGER);
2613
+ async function pages(read, stalled) {
2614
+ const found = [], seen = /* @__PURE__ */ new Set();
2615
+ for (let last;;) {
2616
+ const page = await read(last);
2617
+ const fresh = page.items.filter((item) => !seen.has(item.id));
2618
+ if (!fresh.length && page.items.length) {
2619
+ stalled();
2620
+ return found;
2621
+ }
2622
+ for (const item of fresh) seen.add(item.id);
2623
+ found.push({
2624
+ ...page,
2625
+ items: fresh
2626
+ });
2627
+ if (page.items.length < pageSize) return found;
2628
+ last = page.items.at(-1);
2629
+ }
2630
+ }
2631
+ function grouped(items) {
2632
+ const map = /* @__PURE__ */ new Map();
2633
+ for (const entry of items) map.set(entry.cardId, [...map.get(entry.cardId) ?? [], entry]);
2634
+ return map;
2635
+ }
2636
+ function createPlankaReader(http) {
2637
+ const read = async (source, headers, schema, path, query = {}) => {
2638
+ const url = new URL(`${source.baseUrl}/api/${path}`);
2639
+ for (const [name, value] of Object.entries(query)) url.searchParams.set(name, value);
2640
+ return readable("planka", schema, await readSource(http, "planka", {
2641
+ method: "GET",
2642
+ url: url.toString(),
2643
+ headers: {
2644
+ Accept: "application/json",
2645
+ ...headers
2646
+ },
2647
+ body: ""
2648
+ }));
2649
+ };
2650
+ const session = async (source, use) => {
2651
+ if (source.signIn === "api_key") return use({ "X-Api-Key": source.token });
2652
+ const { item: token } = readable("planka", z.object({ item: z.string() }), await readSource(http, "planka", {
2653
+ method: "POST",
2654
+ url: `${source.baseUrl}/api/access-tokens`,
2655
+ headers: { Accept: "application/json" },
2656
+ body: JSON.stringify({
2657
+ emailOrUsername: source.username,
2658
+ password: source.password
2659
+ })
2660
+ }, byStatus, { retry: false }));
2661
+ const headers = { Authorization: `Bearer ${token}` };
979
2662
  try {
980
- if (ownsLock) await lock.query("SELECT pg_advisory_unlock(7361082)");
2663
+ return await use(headers);
981
2664
  } finally {
982
- lock.release();
2665
+ await http({
2666
+ method: "DELETE",
2667
+ url: `${source.baseUrl}/api/access-tokens/me`,
2668
+ headers,
2669
+ body: ""
2670
+ }).catch(() => void 0);
983
2671
  }
2672
+ };
2673
+ return {
2674
+ projects: (source) => session(source, async (headers) => {
2675
+ const found = await read(source, headers, z.object({
2676
+ items: z.array(z.object({
2677
+ id: z.string(),
2678
+ name: z.string()
2679
+ })),
2680
+ included: z.object({ boards: z.array(z.object({
2681
+ id: z.string(),
2682
+ projectId: z.string(),
2683
+ name: z.string(),
2684
+ position: z.number().nullish()
2685
+ })).default([]) })
2686
+ }), "projects");
2687
+ return found.items.flatMap((project) => found.included.boards.filter((item) => item.projectId === project.id).toSorted(byPosition$1).map((item) => ({
2688
+ ref: item.id,
2689
+ key: "",
2690
+ name: item.name,
2691
+ group: project.name
2692
+ })));
2693
+ }),
2694
+ bundle: (source, boardId, includeComments) => session(source, async (headers) => {
2695
+ const { item, included: contents } = await read(source, headers, board$1, `boards/${encodeURIComponent(boardId)}`);
2696
+ const modern = contents.taskLists !== void 0;
2697
+ const out = bundleBuilder("planka");
2698
+ const lists = [...contents.lists].toSorted(byPosition$1);
2699
+ const states = lists.filter((list) => !list.type || ["active", "closed"].includes(list.type));
2700
+ const workflow = states.map((list) => ({
2701
+ ref: list.id,
2702
+ name: out.clip((list.name ?? "").trim(), 40) || "Untitled list",
2703
+ category: category(list.name ?? "", list.type === "closed" ? "done" : "todo"),
2704
+ color: "default"
2705
+ }));
2706
+ const merged = {
2707
+ ...contents,
2708
+ cards: [...contents.cards],
2709
+ archived: /* @__PURE__ */ new Set()
2710
+ };
2711
+ for (const archive of lists.filter((list) => list.type === "archive")) for (const page of await pages((last) => read(source, headers, cardPage, `lists/${encodeURIComponent(archive.id)}/cards`, last?.listChangedAt ? {
2712
+ "before[listChangedAt]": last.listChangedAt,
2713
+ "before[id]": last.id
2714
+ } : {}), () => out.note("archived cards past the first page", "PLANKA answered the same page again; the archived cards it did not return are left out.", null))) {
2715
+ for (const found of page.items) merged.archived.add(found.id);
2716
+ merged.cards.push(...page.items);
2717
+ merged.users.push(...page.included.users);
2718
+ merged.cardLabels.push(...page.included.cardLabels);
2719
+ merged.cardMemberships.push(...page.included.cardMemberships);
2720
+ merged.taskLists?.push(...page.included.taskLists ?? []);
2721
+ merged.tasks.push(...page.included.tasks);
2722
+ merged.attachments.push(...page.included.attachments);
2723
+ merged.customFieldValues.push(...page.included.customFieldValues);
2724
+ }
2725
+ if (lists.some((list) => list.type === "trash")) out.note("deleted cards", "Cards in PLANKA's trash are left out.", null);
2726
+ const people = /* @__PURE__ */ new Map();
2727
+ const know = (users) => {
2728
+ for (const found of users) people.set(found.id, {
2729
+ ref: found.id,
2730
+ name: found.name || found.username || found.id,
2731
+ email: found.email ?? ""
2732
+ });
2733
+ };
2734
+ know(merged.users);
2735
+ const threads = /* @__PURE__ */ new Map();
2736
+ if (includeComments) for (const found of merged.cards) {
2737
+ if (found.commentsTotal === 0) continue;
2738
+ const thread = await pages((last) => read(source, headers, notes, `cards/${encodeURIComponent(found.id)}/${modern ? "comments" : "actions"}`, last ? { beforeId: last.id } : {}), () => out.note("comments past the first page", "PLANKA answered the same page again; older comments are left out.", null));
2739
+ for (const page of thread) know(page.included.users);
2740
+ threads.set(found.id, thread.flatMap((page) => page.items));
2741
+ }
2742
+ const person = (id) => id ? people.get(id) ?? formerMember(id) : null;
2743
+ const labels = new Map(contents.labels.map((label) => [label.id, label]));
2744
+ const cardLabels = grouped(merged.cardLabels), memberships = grouped(merged.cardMemberships), attachments = grouped(merged.attachments), values = grouped(merged.customFieldValues), taskLists = grouped(merged.taskLists ?? []);
2745
+ const known = new Set(states.map((list) => list.id));
2746
+ const fallback = states.find((list) => list.type === "closed")?.id ?? states.at(-1)?.id ?? "";
2747
+ for (const found of merged.cards) {
2748
+ const names = out.labels((cardLabels.get(found.id) ?? []).flatMap(({ labelId }) => {
2749
+ const label = labels.get(labelId);
2750
+ return label ? [{
2751
+ name: label.name?.trim() || label.color || "label",
2752
+ color: label.color
2753
+ }] : [];
2754
+ }));
2755
+ const checklists = modern ? (taskLists.get(found.id) ?? []).toSorted(byPosition$1).map((list) => ({
2756
+ name: list.name ?? "Tasks",
2757
+ items: merged.tasks.filter((task) => task.taskListId === list.id)
2758
+ })) : [{
2759
+ name: "Tasks",
2760
+ items: merged.tasks.filter((task) => task.cardId === found.id)
2761
+ }];
2762
+ const archived = merged.archived.has(found.id);
2763
+ const stateRef = known.has(found.listId) ? found.listId : found.prevListId && known.has(found.prevListId) ? found.prevListId : fallback;
2764
+ out.ticket(sourceTicket({
2765
+ ref: found.id,
2766
+ key: out.clip(found.name.trim(), 40),
2767
+ title: out.clip(found.name.trim(), 500) || "Untitled card",
2768
+ description: out.clip(withChecklists(found.description ?? "", checklists.map((list) => ({
2769
+ name: list.name,
2770
+ items: list.items.toSorted(byPosition$1).map((task) => ({
2771
+ name: task.name,
2772
+ done: task.isCompleted
2773
+ }))
2774
+ }))), 1e5),
2775
+ priority: "none",
2776
+ kind: kindOf(names),
2777
+ labels: names,
2778
+ stateRef,
2779
+ assigneeRef: out.assignee((memberships.get(found.id) ?? []).flatMap(({ userId }) => person(userId) ?? [])),
2780
+ parentRef: null,
2781
+ createdByRef: out.person(person(found.creatorUserId)),
2782
+ createdAt: isoTime(found.createdAt),
2783
+ dueAt: found.dueDate ? isoTime(found.dueDate) : null,
2784
+ archived
2785
+ }));
2786
+ const files = attachments.get(found.id)?.length ?? 0;
2787
+ if (files) out.note("attachments", "Files and links stay in PLANKA.", files);
2788
+ const filled = values.get(found.id)?.length ?? 0;
2789
+ if (filled) out.note("custom fields", "Custom field values stay in PLANKA.", filled);
2790
+ if (found.stopwatch) out.note("stopwatches", "Kanvil has no time tracking; recorded time stays in PLANKA.");
2791
+ for (const note of (threads.get(found.id) ?? []).toReversed()) {
2792
+ const body = (note.text ?? note.data?.text ?? "").trim();
2793
+ if (note.type && note.type !== "commentCard" || !body) continue;
2794
+ out.comment({
2795
+ ref: note.id,
2796
+ ticketRef: found.id,
2797
+ authorRef: out.person(person(note.userId)),
2798
+ body: out.clip(body, 5e4),
2799
+ createdAt: isoTime(note.createdAt)
2800
+ });
2801
+ }
2802
+ }
2803
+ if (!includeComments) out.note("comments", "Left out when the import was set up.", null);
2804
+ return out.build({
2805
+ ref: item.id,
2806
+ key: "",
2807
+ name: item.name,
2808
+ description: ""
2809
+ }, workflow);
2810
+ })
2811
+ };
2812
+ }
2813
+ //#endregion
2814
+ //#region src/adapters/trello.ts
2815
+ const board = z.object({
2816
+ id: z.string(),
2817
+ name: z.string(),
2818
+ desc: z.string().default("")
2819
+ });
2820
+ const list = z.object({
2821
+ id: z.string(),
2822
+ name: z.string(),
2823
+ closed: z.boolean().default(false)
2824
+ });
2825
+ const label = z.object({
2826
+ name: z.string().nullish(),
2827
+ color: z.string().nullish()
2828
+ });
2829
+ const card = z.object({
2830
+ id: z.string(),
2831
+ idShort: z.number().optional(),
2832
+ name: z.string(),
2833
+ desc: z.string().default(""),
2834
+ due: z.string().nullish(),
2835
+ start: z.string().nullish(),
2836
+ dueComplete: z.boolean().default(false),
2837
+ idList: z.string(),
2838
+ idMembers: z.array(z.string()).default([]),
2839
+ labels: z.array(label).default([]),
2840
+ closed: z.boolean().default(false),
2841
+ badges: z.object({ attachments: z.number().default(0) }).optional()
2842
+ });
2843
+ const checklist = z.object({
2844
+ id: z.string(),
2845
+ idCard: z.string(),
2846
+ name: z.string(),
2847
+ pos: z.number().default(0),
2848
+ checkItems: z.array(z.object({
2849
+ name: z.string(),
2850
+ state: z.string(),
2851
+ pos: z.number().default(0)
2852
+ })).default([])
2853
+ });
2854
+ const member = z.object({
2855
+ id: z.string(),
2856
+ fullName: z.string().nullish(),
2857
+ username: z.string().default("")
2858
+ });
2859
+ const comment = z.object({
2860
+ id: z.string(),
2861
+ date: z.string(),
2862
+ data: z.object({
2863
+ text: z.string().default(""),
2864
+ card: z.object({ id: z.string() }).optional()
2865
+ }),
2866
+ memberCreator: member.nullish()
2867
+ });
2868
+ const pageLimit = 1e3;
2869
+ const byPosition = (a, b) => a.pos - b.pos;
2870
+ const createdAt = (id) => (/* @__PURE__ */ new Date(parseInt(id.slice(0, 8), 16) * 1e3)).toISOString();
2871
+ const personOf = (who) => ({
2872
+ ref: who.id,
2873
+ name: who.fullName || who.username || who.id,
2874
+ email: ""
2875
+ });
2876
+ const judge = (status, data) => status === 400 && data === void 0 ? "rejected" : byStatus(status);
2877
+ const all = async (read) => {
2878
+ const found = /* @__PURE__ */ new Map();
2879
+ for (let before = null;;) {
2880
+ const page = await read(before);
2881
+ const fresh = page.filter((item) => !found.has(item.id));
2882
+ for (const item of fresh) found.set(item.id, item);
2883
+ if (page.length < pageLimit || !fresh.length) return [...found.values()];
2884
+ before = fresh.map((item) => item.id).reduce((a, b) => a < b ? a : b);
984
2885
  }
2886
+ };
2887
+ const paged = (before) => ({
2888
+ limit: pageLimit,
2889
+ ...before ? { before } : {}
2890
+ });
2891
+ function createTrelloReader(http, url) {
2892
+ const get = async (source, schema, path, query = {}) => {
2893
+ const target = new URL(`${url}/1/${path}`);
2894
+ for (const [name, value] of Object.entries(query)) target.searchParams.set(name, String(value));
2895
+ return readable("trello", schema, await readSource(http, "trello", {
2896
+ method: "GET",
2897
+ url: target.toString(),
2898
+ headers: {
2899
+ Accept: "application/json",
2900
+ Authorization: `OAuth oauth_consumer_key="${source.apiKey}", oauth_token="${source.token}"`
2901
+ },
2902
+ body: ""
2903
+ }, judge));
2904
+ };
2905
+ return {
2906
+ async projects(source) {
2907
+ return (await get(source, z.array(board.pick({
2908
+ id: true,
2909
+ name: true
2910
+ })), "members/me/boards", {
2911
+ filter: "all",
2912
+ fields: "name"
2913
+ })).map((item) => ({
2914
+ ref: item.id,
2915
+ key: "",
2916
+ name: item.name,
2917
+ group: ""
2918
+ }));
2919
+ },
2920
+ async bundle(source, boardId, includeComments) {
2921
+ const found = await get(source, board, `boards/${encodeURIComponent(boardId)}`, { fields: "name,desc" });
2922
+ const path = `boards/${encodeURIComponent(found.id)}`;
2923
+ const out = bundleBuilder("trello");
2924
+ const lists = await get(source, z.array(list), `${path}/lists`, {
2925
+ filter: "all",
2926
+ fields: "name,closed"
2927
+ });
2928
+ const members = new Map((await get(source, z.array(member), `${path}/members`, { fields: "fullName,username" })).map((item) => [item.id, personOf(item)]));
2929
+ const cards = await all((before) => get(source, z.array(card), `${path}/cards/all`, {
2930
+ fields: "idShort,name,desc,due,start,dueComplete,idList,idMembers,labels,closed,badges",
2931
+ ...paged(before)
2932
+ }));
2933
+ const checklists = /* @__PURE__ */ new Map();
2934
+ for (const item of await all((before) => get(source, z.array(checklist), `${path}/checklists`, {
2935
+ fields: "idCard,name,pos",
2936
+ checkItems: "all",
2937
+ checkItem_fields: "name,state,pos",
2938
+ ...paged(before)
2939
+ }))) checklists.set(item.idCard, [...checklists.get(item.idCard) ?? [], item]);
2940
+ const used = new Set(cards.map((item) => item.idList));
2941
+ const closedLists = new Set(lists.filter((item) => item.closed).map((item) => item.id));
2942
+ const workflow = lists.filter((item) => !item.closed || used.has(item.id)).map((item) => ({
2943
+ ref: item.id,
2944
+ name: out.clip(item.name.trim(), 40),
2945
+ category: category(item.name, "todo"),
2946
+ color: "default"
2947
+ }));
2948
+ for (const item of cards) {
2949
+ const labels = out.labels(item.labels.map((tag) => ({
2950
+ name: tag.name?.trim() || tag.color || "label",
2951
+ color: tag.color
2952
+ })));
2953
+ const own = (checklists.get(item.id) ?? []).toSorted(byPosition);
2954
+ out.ticket(sourceTicket({
2955
+ ref: item.id,
2956
+ key: item.idShort === void 0 ? "" : `#${item.idShort}`,
2957
+ title: out.clip(item.name.trim(), 500) || "Untitled card",
2958
+ description: out.clip(withChecklists(item.desc, own.map((checks) => ({
2959
+ name: checks.name,
2960
+ items: checks.checkItems.toSorted(byPosition).map((check) => ({
2961
+ name: check.name,
2962
+ done: check.state === "complete"
2963
+ }))
2964
+ }))), 1e5),
2965
+ priority: "none",
2966
+ kind: kindOf(labels),
2967
+ labels,
2968
+ stateRef: item.idList,
2969
+ assigneeRef: out.assignee(item.idMembers.map((id) => members.get(id) ?? formerMember(id))),
2970
+ parentRef: null,
2971
+ createdByRef: null,
2972
+ createdAt: createdAt(item.id),
2973
+ startAt: item.start ? isoTime(item.start) : null,
2974
+ dueAt: item.due ? isoTime(item.due) : null,
2975
+ archived: item.closed || closedLists.has(item.idList)
2976
+ }));
2977
+ if (item.dueComplete) out.note("completed due dates", "Kanvil has no due-date checkmark; the cards keep their due date and list.");
2978
+ if (item.badges?.attachments) out.note("attachments", "Files and links stay in Trello.", item.badges.attachments);
2979
+ }
2980
+ if (includeComments) {
2981
+ const notes = await all((before) => get(source, z.array(comment), `${path}/actions`, {
2982
+ filter: "commentCard",
2983
+ fields: "data,date",
2984
+ memberCreator_fields: "fullName,username",
2985
+ ...paged(before)
2986
+ }));
2987
+ for (const note of notes.toSorted((a, b) => a.date.localeCompare(b.date))) if (note.data.card && note.data.text.trim()) out.comment({
2988
+ ref: note.id,
2989
+ ticketRef: note.data.card.id,
2990
+ authorRef: out.person(note.memberCreator ? personOf(note.memberCreator) : null),
2991
+ body: out.clip(note.data.text, 5e4),
2992
+ createdAt: isoTime(note.date)
2993
+ });
2994
+ } else out.note("comments", "Left out when the import was set up.", null);
2995
+ const fields = await get(source, z.array(z.unknown()), `${path}/customFields`);
2996
+ if (fields.length) out.note("custom fields", "Power-Up custom fields stay in Trello.", fields.length);
2997
+ return out.build({
2998
+ ref: found.id,
2999
+ key: "",
3000
+ name: found.name,
3001
+ description: found.desc
3002
+ }, workflow);
3003
+ }
3004
+ };
985
3005
  }
986
- function createPushDelivery(database, config) {
987
- let running;
988
- const transport = new Expo(config.EXPO_ACCESS_TOKEN ? { accessToken: config.EXPO_ACCESS_TOKEN } : {});
989
- const tick = () => {
990
- if (!running) running = deliverPushBatch(database, config, transport).catch(() => {
991
- console.error("Push delivery failed; queued notifications will be retried.");
992
- }).finally(() => {
993
- running = void 0;
994
- });
3006
+ //#endregion
3007
+ //#region src/adapters/import-sources.ts
3008
+ function createImportSources(http, urls) {
3009
+ const linear = createLinearReader(http, urls.linear), jira = createJiraReader(http), trello = createTrelloReader(http, urls.trello), plane = createPlaneReader(http), planka = createPlankaReader(http), kaneo = createKaneoReader(http);
3010
+ const reader = (source) => {
3011
+ switch (source.provider) {
3012
+ case "linear": return {
3013
+ projects: () => linear.projects(source.token),
3014
+ bundle: (project, comments) => linear.bundle(source.token, project, comments)
3015
+ };
3016
+ case "jira": return {
3017
+ projects: () => jira.projects(source),
3018
+ bundle: (project, comments) => jira.bundle(source, project, comments)
3019
+ };
3020
+ case "trello": return {
3021
+ projects: () => trello.projects(source),
3022
+ bundle: (project, comments) => trello.bundle(source, project, comments)
3023
+ };
3024
+ case "plane": return {
3025
+ projects: () => plane.projects(source),
3026
+ bundle: (project, comments) => plane.bundle(source, project, comments)
3027
+ };
3028
+ case "planka": return {
3029
+ projects: () => planka.projects(source),
3030
+ bundle: (project, comments) => planka.bundle(source, project, comments)
3031
+ };
3032
+ default: return {
3033
+ projects: () => kaneo.projects(source),
3034
+ bundle: (project, comments) => kaneo.bundle(source, project, comments)
3035
+ };
3036
+ }
3037
+ };
3038
+ return {
3039
+ projects: (source) => reader(source).projects(),
3040
+ bundle: (source, project, { includeComments }) => reader(source).bundle(project, includeComments)
995
3041
  };
996
- const timer = config.KANVIL_PUSH_ENABLED ? setInterval(tick, 3e4) : void 0;
997
- timer?.unref();
998
- return { async close() {
999
- clearInterval(timer);
1000
- await running;
1001
- } };
1002
3042
  }
1003
3043
  //#endregion
1004
3044
  //#region src/runtime-adapters.ts
1005
3045
  const defaultAdapters = {
1006
3046
  auth: createAuth,
1007
3047
  realtime: createEventDelivery,
1008
- push: createPushDelivery,
3048
+ senders: defaultSenders,
1009
3049
  objectStore: createObjectStore,
1010
3050
  ticketIndex: createTicketIndex,
1011
- jobs: createPollingJob
3051
+ jobs: createPollingJob,
3052
+ repositories: (config) => ({
3053
+ github: createGitHubDriver(config.KANVIL_GITHUB_API_URL),
3054
+ gitea: createGiteaDriver(),
3055
+ gitlab: createGitLabDriver()
3056
+ }),
3057
+ importSources: (config) => createImportSources(createHttpSender({
3058
+ allowPrivate: config.KANVIL_DELIVERY_ALLOW_PRIVATE,
3059
+ timeout: 3e4,
3060
+ responseLimit: 16777216
3061
+ }), {
3062
+ linear: config.KANVIL_LINEAR_API_URL,
3063
+ trello: config.KANVIL_TRELLO_API_URL
3064
+ })
1012
3065
  };
1013
3066
  //#endregion
1014
3067
  //#region src/mcp.ts
@@ -1075,6 +3128,66 @@ function createMcp(auth, domain, access, config, limitWrite) {
1075
3128
  };
1076
3129
  }
1077
3130
  //#endregion
3131
+ //#region src/reminders.ts
3132
+ const overdueWindow = 6048e5;
3133
+ const source = {
3134
+ kind: "system",
3135
+ id: "due-reminders"
3136
+ };
3137
+ async function sendDueReminders(database, now = /* @__PURE__ */ new Date()) {
3138
+ return database.db.transaction(async (tx) => {
3139
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(7361080)`);
3140
+ const kind = sql`case when ${tickets.dueAt} <= ${now} then 'overdue' else 'due_soon' end`;
3141
+ const lead = sql`(coalesce(${notificationPreferences.reminderAmount}, 1) || ' ' || coalesce(${notificationPreferences.reminderUnit}, 'days'))::interval`;
3142
+ const due = await tx.select({
3143
+ kind,
3144
+ ticketId: tickets.id,
3145
+ projectId: tickets.projectId,
3146
+ key: tickets.key,
3147
+ title: tickets.title,
3148
+ dueAt: tickets.dueAt,
3149
+ ...recipientColumns
3150
+ }).from(tickets).innerJoin(subscriptions, eq(subscriptions.ticketId, tickets.id)).innerJoin(user$1, eq(user$1.id, subscriptions.userId)).leftJoin(notificationPreferences, eq(notificationPreferences.userId, user$1.id)).where(and(isNotNull(tickets.dueAt), eq(tickets.archived, false), sql`not exists (select 1 from projects p where p.id = ${tickets.projectId} and p.archived_at is not null)`, notInArray(tickets.status, [...closedStates]), gt(tickets.dueAt, /* @__PURE__ */ new Date(now.getTime() - overdueWindow)), sql`${tickets.dueAt} - ${lead} <= ${now}`, sql`coalesce((${notificationPreferences.categories} ->> ${kind})::boolean, true)`, reachable(tickets.projectId), sql`not exists (
3151
+ select 1 from ${notifications} join ${events} on ${events.id} = ${notifications.eventId}
3152
+ where ${notifications.userId} = ${user$1.id} and ${events.ticketId} = ${tickets.id}
3153
+ and ${events.type} = 'ticket.' || ${kind}
3154
+ and (${events.data} ->> 'dueAt')::timestamptz = ${tickets.dueAt})`));
3155
+ const groups = /* @__PURE__ */ new Map();
3156
+ for (const row of due) {
3157
+ const group = groups.get(`${row.ticketId}:${row.kind}`) ?? {
3158
+ row,
3159
+ recipients: []
3160
+ };
3161
+ group.recipients.push(row);
3162
+ groups.set(`${row.ticketId}:${row.kind}`, group);
3163
+ }
3164
+ const pending = [];
3165
+ for (const { row, recipients } of groups.values()) {
3166
+ const dueAt = row.dueAt?.toISOString();
3167
+ const [event] = await appendEvents(tx, [{
3168
+ projectId: row.projectId,
3169
+ ticketId: row.ticketId,
3170
+ actorId: null,
3171
+ type: `ticket.${row.kind}`,
3172
+ data: {
3173
+ dueAt,
3174
+ key: row.key,
3175
+ title: row.title,
3176
+ source
3177
+ }
3178
+ }]);
3179
+ const reminderKey = `${row.kind}:${row.ticketId}:${dueAt}`;
3180
+ if (event) pending.push(...recipients.map((recipient) => ({
3181
+ event,
3182
+ recipient,
3183
+ reminderKey
3184
+ })));
3185
+ }
3186
+ await notify(tx, pending);
3187
+ return pending.length;
3188
+ });
3189
+ }
3190
+ //#endregion
1078
3191
  //#region src/app.ts
1079
3192
  const setupInput = z$1.object({
1080
3193
  token: z$1.string().max(200),
@@ -1082,12 +3195,15 @@ const setupInput = z$1.object({
1082
3195
  name: z$1.string().trim().min(1).max(120),
1083
3196
  password: z$1.string().min(12).max(128)
1084
3197
  }).strict();
1085
- const keyInput = z$1.object({
1086
- name: z$1.string().trim().min(1).max(100),
1087
- projectIds: z$1.array(z$1.uuid()).min(1).max(1e3),
1088
- scopes: z$1.array(z$1.enum(scopes)).min(1),
1089
- expiresIn: z$1.number().int().min(3600).max(31536e3).default(7776e3)
1090
- }).strict();
3198
+ const toPreferences = (row) => notificationPreferencesSchema.parse(row ? {
3199
+ inApp: row.inApp,
3200
+ push: row.push,
3201
+ categories: row.categories,
3202
+ reminderLead: {
3203
+ amount: row.reminderAmount,
3204
+ unit: row.reminderUnit
3205
+ }
3206
+ } : {});
1091
3207
  const digest = (value) => createHash("sha256").update(value).digest();
1092
3208
  async function createApplication(database, config, overrides = {}) {
1093
3209
  const adapters = {
@@ -1098,15 +3214,24 @@ async function createApplication(database, config, overrides = {}) {
1098
3214
  const instance = createInstance(integrationDatabase, config);
1099
3215
  await instance.read();
1100
3216
  const blobs = createBlobStorage(integrationDatabase, instance, adapters.objectStore, adapters.jobs), search = createTicketSearch(integrationDatabase, instance, adapters.ticketIndex, adapters.jobs);
3217
+ const secrets = deliverySecrets(config), senders = adapters.senders(config), repositories = adapters.repositories(config);
1101
3218
  const auth = adapters.auth(database, config), domain = createDomain(database, {
1102
3219
  blobs,
1103
- search
3220
+ search,
3221
+ secrets,
3222
+ publicUrl: config.KANVIL_URL,
3223
+ mail: senders.mail,
3224
+ sources: adapters.importSources(config)
1104
3225
  }), access = createAccess(auth, domain, config, database), realtime = await adapters.realtime(config, instance, integrationDatabase);
1105
3226
  const unsubscribeJobs = realtime.subscribe(() => {
1106
3227
  blobs.sync().catch(() => {});
1107
3228
  search.sync().catch(() => {});
1108
3229
  });
1109
- const push = adapters.push(database, config);
3230
+ const outbox = adapters.jobs("outbox", 5e3, () => deliverBatch(database, config, senders, secrets, repositories));
3231
+ const reminders = adapters.jobs("due-reminders", 6e4, () => sendDueReminders(database));
3232
+ const imports = adapters.jobs("imports", 2e3, async () => {
3233
+ if (await processImports(domain)) imports.trigger().catch(() => {});
3234
+ });
1110
3235
  const accounts = createAccountAdministration(integrationDatabase, auth, config);
1111
3236
  const app = new OpenAPIHono({ defaultHook: (result) => {
1112
3237
  if (!result.success) return Response.json({ error: {
@@ -1170,20 +3295,23 @@ async function createApplication(database, config, overrides = {}) {
1170
3295
  return Response.json({ status: "ok" });
1171
3296
  });
1172
3297
  const setupRequired = async () => (await database.pool.query("SELECT count(*)::int AS count FROM \"user\"")).rows[0]?.count === 0;
1173
- app.get("/api/v1/info", async () => Response.json({
1174
- ...await instance.read().then((row) => ({
1175
- name: row.name,
1176
- description: row.description
1177
- })),
1178
- version: applicationVersion,
1179
- authentication: await instance.authentication(),
1180
- setupRequired: await setupRequired(),
1181
- push: config.KANVIL_PUSH_ENABLED,
1182
- oidc: config.OIDC_DISCOVERY_URL ? {
1183
- id: "oidc",
1184
- name: config.OIDC_NAME
1185
- } : null
1186
- }));
3298
+ app.get("/api/v1/info", async () => {
3299
+ const authentication = await instance.authentication(), providers = signInProviders(config);
3300
+ return Response.json({
3301
+ ...await instance.read().then((row) => ({
3302
+ name: row.name,
3303
+ description: row.description
3304
+ })),
3305
+ version: applicationVersion,
3306
+ authentication,
3307
+ setupRequired: await setupRequired(),
3308
+ push: config.KANVIL_PUSH_ENABLED,
3309
+ email: !!config.SMTP_URL,
3310
+ emailCodes: emailCodesAvailable(config, authentication),
3311
+ oidc: providers.find((provider) => provider.id === "oidc") ?? null,
3312
+ providers
3313
+ });
3314
+ });
1187
3315
  app.post("/api/v1/setup", async (c) => {
1188
3316
  access.checkOrigin(c.req.raw);
1189
3317
  const input = setupInput.parse(await c.req.json());
@@ -1203,6 +3331,7 @@ async function createApplication(database, config, overrides = {}) {
1203
3331
  userId: created.user.id,
1204
3332
  role: "admin"
1205
3333
  });
3334
+ await tx.update(workspaces).set({ ownerId: created.user.id }).where(and(eq(workspaces.id, workspace.id), isNull(workspaces.ownerId)));
1206
3335
  });
1207
3336
  return Response.json({ ok: true }, { status: 201 });
1208
3337
  });
@@ -1214,6 +3343,43 @@ async function createApplication(database, config, overrides = {}) {
1214
3343
  const writes = createWriteLimiter(config.KANVIL_SESSION_WRITE_LIMIT);
1215
3344
  const mcpHandler = createMcp(auth, domain, access, config, (key) => writes.consume(key));
1216
3345
  app.all("/mcp", (c) => mcpHandler(c.req.raw));
3346
+ app.openAPIRegistry.registerPath({
3347
+ method: "get",
3348
+ path: "/api/calendar/{token}.ics",
3349
+ request: { params: z$1.object({ token: z$1.string() }) },
3350
+ responses: {
3351
+ 200: {
3352
+ description: "iCalendar feed",
3353
+ content: { "text/calendar": { schema: z$1.string() } }
3354
+ },
3355
+ 404: { description: "Feed unavailable" }
3356
+ }
3357
+ });
3358
+ app.get("/api/calendar/:file", async (c) => {
3359
+ const token = /^(.+)\.ics$/.exec(c.req.param("file"))?.[1], body = token ? await readCalendarFeed(database.db, token, config.KANVIL_URL) : null;
3360
+ if (body === null) fail(404, "not_found", "This calendar feed does not exist or was revoked");
3361
+ return new Response(body, { headers: {
3362
+ "Content-Type": "text/calendar; charset=utf-8",
3363
+ "Content-Disposition": "inline; filename=\"kanvil.ics\"",
3364
+ "Cache-Control": "no-store",
3365
+ "Referrer-Policy": "no-referrer"
3366
+ } });
3367
+ });
3368
+ app.openAPIRegistry.registerPath({
3369
+ method: "post",
3370
+ path: "/api/repository-hooks/{connectionId}",
3371
+ request: { params: z$1.object({ connectionId: z$1.uuid() }) },
3372
+ responses: {
3373
+ 200: { description: "Already handled; nothing applied again" },
3374
+ 202: { description: "Handled; the body names the outcome per linked ticket" },
3375
+ 401: { description: "Signature does not match" },
3376
+ 404: { description: "Connection unavailable" }
3377
+ }
3378
+ });
3379
+ app.post("/api/repository-hooks/:connectionId", async (c) => {
3380
+ const result = await receiveRepositoryWebhook(domain, repositories, secrets, c.req.param("connectionId"), c.req.raw.headers, await c.req.text());
3381
+ return c.json(result, result.outcome === "duplicate" ? 200 : 202);
3382
+ });
1217
3383
  app.use("/api/v1/*", async (c, next) => {
1218
3384
  access.checkOrigin(c.req.raw);
1219
3385
  const actor = await access.authenticate(c.req.raw);
@@ -1231,29 +3397,39 @@ async function createApplication(database, config, overrides = {}) {
1231
3397
  });
1232
3398
  registerInstanceRoutes(app, database, instance, blobs, search, realtime, auth);
1233
3399
  accounts.register(app);
1234
- app.openAPIRegistry.registerPath({
1235
- method: "get",
1236
- path: "/api/v1/projects/{projectId}/icon",
1237
- security: [{ sessionCookie: [] }],
1238
- request: { params: z$1.object({ projectId: z$1.string() }) },
1239
- responses: {
1240
- 200: {
1241
- description: "Private project icon",
1242
- content: {
1243
- "image/png": { schema: z$1.string() },
1244
- "image/jpeg": { schema: z$1.string() },
1245
- "image/webp": { schema: z$1.string() }
1246
- }
1247
- },
1248
- 404: { description: "Project icon unavailable" }
1249
- }
1250
- });
1251
- app.get("/api/v1/projects/:projectId/icon", async (c) => {
1252
- const project = await domain.execute("get_project", { projectId: c.req.param("projectId") }, c.get("actor"));
1253
- const row = one(await database.db.select({ objectId: projects.iconObjectId }).from(projects).where(eq(projects.id, project.id)));
1254
- if (!row.objectId) fail(404, "not_found", "This project has no uploaded icon");
1255
- return imageResponse(await blobs.get(row.objectId));
1256
- });
3400
+ for (const [image, column, description] of [[
3401
+ "icon",
3402
+ projects.iconObjectId,
3403
+ "Private project icon"
3404
+ ], [
3405
+ "background",
3406
+ projects.backgroundObjectId,
3407
+ "Private project background"
3408
+ ]]) {
3409
+ app.openAPIRegistry.registerPath({
3410
+ method: "get",
3411
+ path: `/api/v1/projects/{projectId}/${image}`,
3412
+ security: [{ sessionCookie: [] }],
3413
+ request: { params: z$1.object({ projectId: z$1.string() }) },
3414
+ responses: {
3415
+ 200: {
3416
+ description,
3417
+ content: {
3418
+ "image/png": { schema: z$1.string() },
3419
+ "image/jpeg": { schema: z$1.string() },
3420
+ "image/webp": { schema: z$1.string() }
3421
+ }
3422
+ },
3423
+ 404: { description: `Project ${image} unavailable` }
3424
+ }
3425
+ });
3426
+ app.get(`/api/v1/projects/:projectId/${image}`, async (c) => {
3427
+ const project = await domain.execute("get_project", { projectId: c.req.param("projectId") }, c.get("actor"));
3428
+ const row = one(await database.db.select({ objectId: column }).from(projects).where(eq(projects.id, project.id)));
3429
+ if (!row.objectId) fail(404, "not_found", `This project has no uploaded ${image}`);
3430
+ return imageResponse(await blobs.get(row.objectId));
3431
+ });
3432
+ }
1257
3433
  app.get("/api/v1/credentials", async (c) => {
1258
3434
  const identity = c.get("actor");
1259
3435
  if (identity.machine) fail(403, "forbidden", "Use a signed-in account to manage credentials");
@@ -1272,8 +3448,8 @@ async function createApplication(database, config, overrides = {}) {
1272
3448
  app.post("/api/v1/credentials", async (c) => {
1273
3449
  const identity = c.get("actor");
1274
3450
  if (identity.machine) fail(403, "forbidden", "Use a signed-in account to manage credentials");
1275
- const input = keyInput.parse(await c.req.json()), allowed = await domain.accessibleProjects(identity);
1276
- if (input.projectIds.some((id) => !allowed.some((row) => row.id === id))) fail(403, "forbidden", "Choose projects you can access");
3451
+ const input = credentialInput.parse(await c.req.json()), allowed = await domain.accessibleProjects(identity);
3452
+ if (input.projectIds?.some((id) => !allowed.some((row) => row.id === id))) fail(403, "forbidden", "Choose projects you can access");
1277
3453
  const key = await auth.api.createApiKey({ body: {
1278
3454
  userId: identity.id,
1279
3455
  name: input.name,
@@ -1309,14 +3485,23 @@ async function createApplication(database, config, overrides = {}) {
1309
3485
  return Response.json({ cursor: row.value.toString() });
1310
3486
  });
1311
3487
  app.get("/api/v1/projects/:projectId/attachments/:id/content", async (c) => {
1312
- const id = z$1.uuid().parse(c.req.param("id"));
1313
- const row = one(await database.db.select().from(attachments).where(eq(attachments.id, id)));
1314
- const authorized = await domain.execute("list_attachments", {
1315
- projectId: c.req.param("projectId"),
1316
- ticketId: row.ticketId
1317
- }, c.get("actor"));
1318
- requireRow(authorized.find((item) => item.id === id));
1319
- const bytes = row.objectId ? (await blobs.get(row.objectId)).data : one(await database.db.select({ data: attachmentContent.data }).from(attachmentContent).where(eq(attachmentContent.id, id))).data;
3488
+ const id = z$1.uuid().parse(c.req.param("id")), projectId = c.req.param("projectId"), actor = c.get("actor");
3489
+ const attached = (await database.db.select().from(attachments).where(eq(attachments.id, id)))[0];
3490
+ let row, objectId;
3491
+ if (attached) {
3492
+ const authorized = await domain.execute("list_attachments", {
3493
+ projectId: attached.projectId,
3494
+ ticketId: attached.ticketId
3495
+ }, actor);
3496
+ requireRow(authorized.find((item) => item.id === id));
3497
+ row = attached;
3498
+ objectId = attached.objectId;
3499
+ } else {
3500
+ const visible = await domain.execute("get_project", { projectId }, actor);
3501
+ row = one(await database.db.select().from(stagedUploads).where(and(eq(stagedUploads.id, id), eq(stagedUploads.projectId, visible.id), eq(stagedUploads.actorId, actor.id), gt(stagedUploads.createdAt, stagedUploadCutoff()))));
3502
+ objectId = id;
3503
+ }
3504
+ const bytes = objectId ? (await blobs.get(objectId)).data : one(await database.db.select({ data: attachmentContent.data }).from(attachmentContent).where(eq(attachmentContent.id, id))).data;
1320
3505
  if (bytes.length !== row.size) fail(500, "attachment_corrupt", "Attachment integrity check failed");
1321
3506
  return new Response(bytes, { headers: {
1322
3507
  "Content-Type": row.contentType,
@@ -1328,25 +3513,30 @@ async function createApplication(database, config, overrides = {}) {
1328
3513
  });
1329
3514
  app.get("/api/v1/notifications/preferences", async (c) => {
1330
3515
  const row = (await database.db.select().from(notificationPreferences).where(eq(notificationPreferences.userId, c.get("actor").id)))[0];
1331
- return Response.json(notificationPreferencesSchema.parse(row ? {
1332
- inApp: row.inApp,
1333
- push: row.push
1334
- } : {}));
3516
+ return Response.json(toPreferences(row));
1335
3517
  });
1336
3518
  app.put("/api/v1/notifications/preferences", async (c) => {
1337
3519
  const identity = requireSession(c.get("actor"));
1338
- const input = notificationPreferencesInput.parse(await c.req.json());
3520
+ const { categories, reminderLead, ...channels } = notificationPreferencesInput.parse(await c.req.json());
3521
+ const values = {
3522
+ ...channels,
3523
+ ...reminderLead && {
3524
+ reminderAmount: reminderLead.amount,
3525
+ reminderUnit: reminderLead.unit
3526
+ }
3527
+ };
1339
3528
  const row = one(await database.db.insert(notificationPreferences).values({
1340
3529
  userId: identity.id,
1341
- ...input
3530
+ ...values,
3531
+ categories: categories ?? {}
1342
3532
  }).onConflictDoUpdate({
1343
3533
  target: notificationPreferences.userId,
1344
- set: input
1345
- }).returning({
1346
- inApp: notificationPreferences.inApp,
1347
- push: notificationPreferences.push
1348
- }));
1349
- return Response.json(notificationPreferencesSchema.parse(row));
3534
+ set: {
3535
+ ...values,
3536
+ ...categories && { categories: sql`${notificationPreferences.categories} || ${JSON.stringify(categories)}::jsonb` }
3537
+ }
3538
+ }).returning());
3539
+ return Response.json(toPreferences(row));
1350
3540
  });
1351
3541
  app.post("/api/v1/notifications/device", async (c) => {
1352
3542
  const identity = requireSession(c.get("actor"));
@@ -1378,13 +3568,19 @@ async function createApplication(database, config, overrides = {}) {
1378
3568
  return Response.json(json(await database.db.select({
1379
3569
  id: notifications.id,
1380
3570
  readAt: notifications.readAt,
3571
+ category: notifications.category,
3572
+ createdAt: notifications.createdAt,
1381
3573
  event: events
1382
- }).from(notifications).innerJoin(events, eq(events.id, notifications.eventId)).where(and(eq(notifications.userId, identity.id), eq(notifications.inbox, true), inArray(notifications.projectId, allowed.map((row) => row.id)))).orderBy(desc(notifications.createdAt)).limit(100)));
3574
+ }).from(notifications).innerJoin(events, eq(events.id, notifications.eventId)).where(and(eq(notifications.userId, identity.id), eq(notifications.inbox, true), isNull(notifications.dismissedAt), inArray(notifications.projectId, allowed.map((row) => row.id)))).orderBy(desc(notifications.createdAt)).limit(100)));
1383
3575
  });
1384
3576
  app.post("/api/v1/notifications/read", async (c) => {
1385
- requireSession(c.get("actor"));
1386
- const input = z$1.object({ ids: z$1.array(z$1.uuid()).min(1).max(100) }).parse(await c.req.json());
1387
- await database.db.update(notifications).set({ readAt: /* @__PURE__ */ new Date() }).where(and(eq(notifications.userId, c.get("actor").id), inArray(notifications.id, input.ids)));
3577
+ const identity = requireSession(c.get("actor")), input = notificationReadInput.parse(await c.req.json());
3578
+ await database.db.update(notifications).set({ readAt: input.read ? sql`coalesce(${notifications.readAt}, now())` : null }).where(and(eq(notifications.userId, identity.id), inArray(notifications.id, input.ids)));
3579
+ return Response.json({ ok: true });
3580
+ });
3581
+ app.post("/api/v1/notifications/dismiss", async (c) => {
3582
+ const identity = requireSession(c.get("actor")), input = notificationIdsInput.parse(await c.req.json());
3583
+ await database.db.update(notifications).set({ dismissedAt: /* @__PURE__ */ new Date() }).where(and(eq(notifications.userId, identity.id), inArray(notifications.id, input.ids), isNull(notifications.dismissedAt)));
1388
3584
  return Response.json({ ok: true });
1389
3585
  });
1390
3586
  app.get("/api/v1/events", async (c) => {
@@ -1461,18 +3657,18 @@ async function createApplication(database, config, overrides = {}) {
1461
3657
  await done;
1462
3658
  });
1463
3659
  });
1464
- for (const name of Object.keys(operations).filter(isOperation)) {
1465
- const spec = operations[name], params = {}, rest = {};
3660
+ const register = (name, spec, base, secured, run) => {
3661
+ const params = {}, rest = {};
1466
3662
  const keys = [...spec.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
1467
3663
  for (const [key, value] of Object.entries(spec.input.shape)) if (keys.includes(key)) params[key] = value;
1468
3664
  else rest[key] = value;
1469
3665
  const method = spec.method === "GET" ? "get" : spec.method === "POST" ? "post" : spec.method === "PATCH" ? "patch" : "delete";
1470
3666
  const route = createRoute({
1471
3667
  method,
1472
- path: `/api/v1${spec.path}`,
3668
+ path: `${base}${spec.path}`,
1473
3669
  operationId: name,
1474
3670
  description: spec.description,
1475
- security: [{ bearerAuth: [] }],
3671
+ security: secured ? [{ bearerAuth: [] }] : [],
1476
3672
  request: {
1477
3673
  params: z$1.object(params),
1478
3674
  ...method === "get" ? { query: z$1.object(rest).strict() } : { body: {
@@ -1489,21 +3685,31 @@ async function createApplication(database, config, overrides = {}) {
1489
3685
  description: "Invalid input",
1490
3686
  content: { "application/json": { schema: errorSchema } }
1491
3687
  },
1492
- 401: { description: "Authentication required" },
1493
- 403: { description: "Insufficient permissions" },
3688
+ ...secured ? {
3689
+ 401: { description: "Authentication required" },
3690
+ 403: { description: "Insufficient permissions" }
3691
+ } : {},
1494
3692
  404: { description: "Not found" },
1495
- 409: { description: "Version, ownership, or dependency conflict" },
1496
- 429: { description: "Too many writes from this session; see Retry-After" }
3693
+ ...secured ? {
3694
+ 409: { description: "Version, ownership, or dependency conflict" },
3695
+ 429: { description: "Too many writes from this session; see Retry-After" }
3696
+ } : {}
1497
3697
  }
1498
3698
  });
1499
3699
  app.openapi(route, async (c) => {
1500
- const values = spec.method === "GET" ? c.req.query() : z$1.record(z$1.string(), z$1.unknown()).parse(await c.req.json());
1501
- return Response.json(await domain.execute(name, {
1502
- ...values,
3700
+ return run({
3701
+ ...spec.method === "GET" ? Object.fromEntries(Object.entries(c.req.queries()).map(([key, list]) => [key, list.length === 1 ? list[0] : list])) : z$1.record(z$1.string(), z$1.unknown()).parse(await c.req.json()),
1503
3702
  ...c.req.param()
1504
- }, c.get("actor")));
3703
+ }, () => c.get("actor"));
1505
3704
  });
1506
- }
3705
+ };
3706
+ for (const name of Object.keys(operations).filter(isOperation)) register(name, operations[name], "/api/v1", true, async (values, actor) => Response.json(await domain.execute(name, values, actor())));
3707
+ app.use("/api/public/*", async (c, next) => {
3708
+ await next();
3709
+ c.header("Cache-Control", "no-store");
3710
+ c.header("Referrer-Policy", "no-referrer");
3711
+ });
3712
+ for (const name of Object.keys(publicOperations).filter(isPublicOperation)) register(name, publicOperations[name], "/api/public", false, async (values) => Response.json(await domain.readPublic(name, values)));
1507
3713
  app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
1508
3714
  type: "http",
1509
3715
  scheme: "bearer",
@@ -1552,12 +3758,14 @@ async function createApplication(database, config, overrides = {}) {
1552
3758
  search,
1553
3759
  realtime,
1554
3760
  close: async () => {
3761
+ await imports.close();
1555
3762
  unsubscribeJobs();
1556
3763
  await realtime.close();
1557
3764
  await search.close();
1558
3765
  await blobs.close();
1559
3766
  await integrationDatabase.pool.end();
1560
- await push.close();
3767
+ await outbox.close();
3768
+ await reminders.close();
1561
3769
  }
1562
3770
  };
1563
3771
  }