@open-mercato/core 0.10.1-develop.7717.1.2befe9c634 → 0.10.1-develop.7721.1.b5c4a00a68

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 (384) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/staff_support_routing_rule/index.js +21 -0
  3. package/dist/generated/entities/staff_support_routing_rule/index.js.map +7 -0
  4. package/dist/generated/entities/staff_support_ticket/index.js +37 -0
  5. package/dist/generated/entities/staff_support_ticket/index.js.map +7 -0
  6. package/dist/generated/entities/staff_time_project/index.js +2 -0
  7. package/dist/generated/entities/staff_time_project/index.js.map +2 -2
  8. package/dist/generated/entities/staff_time_project_member/index.js +2 -0
  9. package/dist/generated/entities/staff_time_project_member/index.js.map +2 -2
  10. package/dist/generated/entities/staff_time_task/index.js +4 -0
  11. package/dist/generated/entities/staff_time_task/index.js.map +2 -2
  12. package/dist/generated/entities/staff_time_task_comment/index.js +9 -1
  13. package/dist/generated/entities/staff_time_task_comment/index.js.map +2 -2
  14. package/dist/generated/entities.ids.generated.js +2 -0
  15. package/dist/generated/entities.ids.generated.js.map +2 -2
  16. package/dist/generated/entity-fields-registry.js +38 -0
  17. package/dist/generated/entity-fields-registry.js.map +2 -2
  18. package/dist/helpers/integration/auth.js +1 -0
  19. package/dist/helpers/integration/auth.js.map +2 -2
  20. package/dist/helpers/integration/communicationChannelsFixtures.js +20 -0
  21. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  22. package/dist/modules/catalog/api/bulk-delete/route.js +12 -2
  23. package/dist/modules/catalog/api/bulk-delete/route.js.map +2 -2
  24. package/dist/modules/communication_channels/api/post/test-seed/route.js +17 -3
  25. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  26. package/dist/modules/communication_channels/lib/test-seed.js +38 -5
  27. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  28. package/dist/modules/messages/commands/messages.js +2 -3
  29. package/dist/modules/messages/commands/messages.js.map +2 -2
  30. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +2 -2
  31. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
  32. package/dist/modules/staff/api/portal/support-tickets/[id]/attachments/route.js +299 -0
  33. package/dist/modules/staff/api/portal/support-tickets/[id]/attachments/route.js.map +7 -0
  34. package/dist/modules/staff/api/portal/support-tickets/[id]/messages/route.js +91 -0
  35. package/dist/modules/staff/api/portal/support-tickets/[id]/messages/route.js.map +7 -0
  36. package/dist/modules/staff/api/portal/support-tickets/[id]/route.js +146 -0
  37. package/dist/modules/staff/api/portal/support-tickets/[id]/route.js.map +7 -0
  38. package/dist/modules/staff/api/portal/support-tickets/options/route.js +60 -0
  39. package/dist/modules/staff/api/portal/support-tickets/options/route.js.map +7 -0
  40. package/dist/modules/staff/api/portal/support-tickets/route.js +220 -0
  41. package/dist/modules/staff/api/portal/support-tickets/route.js.map +7 -0
  42. package/dist/modules/staff/api/timesheets/settings/route.js +7 -1
  43. package/dist/modules/staff/api/timesheets/settings/route.js.map +2 -2
  44. package/dist/modules/staff/api/timesheets/support-routing-rules/fallback-project/route.js +122 -0
  45. package/dist/modules/staff/api/timesheets/support-routing-rules/fallback-project/route.js.map +7 -0
  46. package/dist/modules/staff/api/timesheets/support-routing-rules/preview/route.js +180 -0
  47. package/dist/modules/staff/api/timesheets/support-routing-rules/preview/route.js.map +7 -0
  48. package/dist/modules/staff/api/timesheets/support-routing-rules/route.js +147 -0
  49. package/dist/modules/staff/api/timesheets/support-routing-rules/route.js.map +7 -0
  50. package/dist/modules/staff/api/timesheets/support-tickets/options/route.js +133 -0
  51. package/dist/modules/staff/api/timesheets/support-tickets/options/route.js.map +7 -0
  52. package/dist/modules/staff/api/timesheets/tasks/[id]/claim/route.js +33 -0
  53. package/dist/modules/staff/api/timesheets/tasks/[id]/claim/route.js.map +7 -0
  54. package/dist/modules/staff/api/timesheets/tasks/[id]/comments/route.js +25 -7
  55. package/dist/modules/staff/api/timesheets/tasks/[id]/comments/route.js.map +2 -2
  56. package/dist/modules/staff/api/timesheets/tasks/[id]/release/route.js +32 -0
  57. package/dist/modules/staff/api/timesheets/tasks/[id]/release/route.js.map +7 -0
  58. package/dist/modules/staff/api/timesheets/tasks/route.js +94 -47
  59. package/dist/modules/staff/api/timesheets/tasks/route.js.map +2 -2
  60. package/dist/modules/staff/api/timesheets/time-projects/[id]/employees/route.js +3 -0
  61. package/dist/modules/staff/api/timesheets/time-projects/[id]/employees/route.js.map +2 -2
  62. package/dist/modules/staff/api/timesheets/time-projects/route.js +3 -0
  63. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  64. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +1 -0
  65. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  66. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +54 -4
  67. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  68. package/dist/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.js +21 -5
  69. package/dist/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.js.map +2 -2
  70. package/dist/modules/staff/backend/staff/time-tracking/settings/page.js +319 -254
  71. package/dist/modules/staff/backend/staff/time-tracking/settings/page.js.map +2 -2
  72. package/dist/modules/staff/backend/staff/time-tracking/support/page.js +363 -0
  73. package/dist/modules/staff/backend/staff/time-tracking/support/page.js.map +7 -0
  74. package/dist/modules/staff/backend/staff/time-tracking/support/page.meta.js +18 -0
  75. package/dist/modules/staff/backend/staff/time-tracking/support/page.meta.js.map +7 -0
  76. package/dist/modules/staff/cli.js +27 -0
  77. package/dist/modules/staff/cli.js.map +2 -2
  78. package/dist/modules/staff/commands/index.js +3 -0
  79. package/dist/modules/staff/commands/index.js.map +2 -2
  80. package/dist/modules/staff/commands/shared.js +10 -1
  81. package/dist/modules/staff/commands/shared.js.map +2 -2
  82. package/dist/modules/staff/commands/support-routing-rules.js +505 -0
  83. package/dist/modules/staff/commands/support-routing-rules.js.map +7 -0
  84. package/dist/modules/staff/commands/support-tickets.js +513 -0
  85. package/dist/modules/staff/commands/support-tickets.js.map +7 -0
  86. package/dist/modules/staff/commands/timesheets-projects.js +72 -14
  87. package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
  88. package/dist/modules/staff/commands/timesheets-task-assignment.js +270 -0
  89. package/dist/modules/staff/commands/timesheets-task-assignment.js.map +7 -0
  90. package/dist/modules/staff/commands/timesheets-task-comments.js +58 -12
  91. package/dist/modules/staff/commands/timesheets-task-comments.js.map +2 -2
  92. package/dist/modules/staff/commands/timesheets-tasks.js +91 -9
  93. package/dist/modules/staff/commands/timesheets-tasks.js.map +3 -3
  94. package/dist/modules/staff/data/enrichers.js +141 -0
  95. package/dist/modules/staff/data/enrichers.js.map +2 -2
  96. package/dist/modules/staff/data/entities.js +143 -2
  97. package/dist/modules/staff/data/entities.js.map +2 -2
  98. package/dist/modules/staff/data/extensions.js +8 -0
  99. package/dist/modules/staff/data/extensions.js.map +2 -2
  100. package/dist/modules/staff/data/validators.js +34 -5
  101. package/dist/modules/staff/data/validators.js.map +2 -2
  102. package/dist/modules/staff/encryption.js +6 -0
  103. package/dist/modules/staff/encryption.js.map +2 -2
  104. package/dist/modules/staff/events.js +25 -0
  105. package/dist/modules/staff/events.js.map +2 -2
  106. package/dist/modules/staff/events.payloads.js +54 -0
  107. package/dist/modules/staff/events.payloads.js.map +2 -2
  108. package/dist/modules/staff/extension-points.js +1 -0
  109. package/dist/modules/staff/extension-points.js.map +2 -2
  110. package/dist/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.js +468 -0
  111. package/dist/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.js.map +7 -0
  112. package/dist/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.meta.js +12 -0
  113. package/dist/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.meta.js.map +7 -0
  114. package/dist/modules/staff/frontend/[orgSlug]/portal/support/new/page.js +316 -0
  115. package/dist/modules/staff/frontend/[orgSlug]/portal/support/new/page.js.map +7 -0
  116. package/dist/modules/staff/frontend/[orgSlug]/portal/support/new/page.meta.js +12 -0
  117. package/dist/modules/staff/frontend/[orgSlug]/portal/support/new/page.meta.js.map +7 -0
  118. package/dist/modules/staff/frontend/[orgSlug]/portal/support/page.js +190 -0
  119. package/dist/modules/staff/frontend/[orgSlug]/portal/support/page.js.map +7 -0
  120. package/dist/modules/staff/frontend/[orgSlug]/portal/support/page.meta.js +19 -0
  121. package/dist/modules/staff/frontend/[orgSlug]/portal/support/page.meta.js.map +7 -0
  122. package/dist/modules/staff/index.js +1 -1
  123. package/dist/modules/staff/index.js.map +2 -2
  124. package/dist/modules/staff/lib/crud.js +2 -0
  125. package/dist/modules/staff/lib/crud.js.map +2 -2
  126. package/dist/modules/staff/lib/support/attachments.js +36 -0
  127. package/dist/modules/staff/lib/support/attachments.js.map +7 -0
  128. package/dist/modules/staff/lib/support/dictionaries.js +87 -0
  129. package/dist/modules/staff/lib/support/dictionaries.js.map +7 -0
  130. package/dist/modules/staff/lib/support/notifications.js +111 -0
  131. package/dist/modules/staff/lib/support/notifications.js.map +7 -0
  132. package/dist/modules/staff/lib/support/portalAttachments.js +100 -0
  133. package/dist/modules/staff/lib/support/portalAttachments.js.map +7 -0
  134. package/dist/modules/staff/lib/support/portalBroadcast.js +117 -0
  135. package/dist/modules/staff/lib/support/portalBroadcast.js.map +7 -0
  136. package/dist/modules/staff/lib/support/portalRoute.js +99 -0
  137. package/dist/modules/staff/lib/support/portalRoute.js.map +7 -0
  138. package/dist/modules/staff/lib/support/portalStatus.js +12 -0
  139. package/dist/modules/staff/lib/support/portalStatus.js.map +7 -0
  140. package/dist/modules/staff/lib/support/portalTickets.js +148 -0
  141. package/dist/modules/staff/lib/support/portalTickets.js.map +7 -0
  142. package/dist/modules/staff/lib/support/routing.js +81 -0
  143. package/dist/modules/staff/lib/support/routing.js.map +7 -0
  144. package/dist/modules/staff/lib/support/routingRuleDraft.js +25 -0
  145. package/dist/modules/staff/lib/support/routingRuleDraft.js.map +7 -0
  146. package/dist/modules/staff/lib/support/seedDefaults.js +114 -0
  147. package/dist/modules/staff/lib/support/seedDefaults.js.map +7 -0
  148. package/dist/modules/staff/lib/support/supportActivity.js +26 -0
  149. package/dist/modules/staff/lib/support/supportActivity.js.map +7 -0
  150. package/dist/modules/staff/lib/support-ui/PortalSupportNotLinked.js +23 -0
  151. package/dist/modules/staff/lib/support-ui/PortalSupportNotLinked.js.map +7 -0
  152. package/dist/modules/staff/lib/support-ui/portalAttachmentValidation.js +112 -0
  153. package/dist/modules/staff/lib/support-ui/portalAttachmentValidation.js.map +7 -0
  154. package/dist/modules/staff/lib/support-ui/portalPendingUploads.js +45 -0
  155. package/dist/modules/staff/lib/support-ui/portalPendingUploads.js.map +7 -0
  156. package/dist/modules/staff/lib/support-ui/portalSupport.js +51 -0
  157. package/dist/modules/staff/lib/support-ui/portalSupport.js.map +7 -0
  158. package/dist/modules/staff/lib/time-tracking/access.js +3 -39
  159. package/dist/modules/staff/lib/time-tracking/access.js.map +2 -2
  160. package/dist/modules/staff/lib/time-tracking/listProjectAccess.js +54 -0
  161. package/dist/modules/staff/lib/time-tracking/listProjectAccess.js.map +7 -0
  162. package/dist/modules/staff/lib/time-tracking/portalRecipients.js +92 -2
  163. package/dist/modules/staff/lib/time-tracking/portalRecipients.js.map +2 -2
  164. package/dist/modules/staff/lib/time-tracking/projectAssignment.js +112 -0
  165. package/dist/modules/staff/lib/time-tracking/projectAssignment.js.map +7 -0
  166. package/dist/modules/staff/lib/time-tracking/settingKeys.js +14 -0
  167. package/dist/modules/staff/lib/time-tracking/settingKeys.js.map +2 -2
  168. package/dist/modules/staff/lib/time-tracking/settings.js +28 -1
  169. package/dist/modules/staff/lib/time-tracking/settings.js.map +2 -2
  170. package/dist/modules/staff/lib/time-tracking/taskAssignmentRoute.js +143 -0
  171. package/dist/modules/staff/lib/time-tracking/taskAssignmentRoute.js.map +7 -0
  172. package/dist/modules/staff/lib/time-tracking/workRecipients.js +92 -0
  173. package/dist/modules/staff/lib/time-tracking/workRecipients.js.map +7 -0
  174. package/dist/modules/staff/lib/time-tracking-ui/BoardFilterChips.js +32 -3
  175. package/dist/modules/staff/lib/time-tracking-ui/BoardFilterChips.js.map +2 -2
  176. package/dist/modules/staff/lib/time-tracking-ui/KanbanBoard.js +29 -0
  177. package/dist/modules/staff/lib/time-tracking-ui/KanbanBoard.js.map +2 -2
  178. package/dist/modules/staff/lib/time-tracking-ui/KanbanCard.js +4 -0
  179. package/dist/modules/staff/lib/time-tracking-ui/KanbanCard.js.map +2 -2
  180. package/dist/modules/staff/lib/time-tracking-ui/ProjectFormSections.js +141 -1
  181. package/dist/modules/staff/lib/time-tracking-ui/ProjectFormSections.js.map +2 -2
  182. package/dist/modules/staff/lib/time-tracking-ui/SupportAttachmentsSection.js +153 -0
  183. package/dist/modules/staff/lib/time-tracking-ui/SupportAttachmentsSection.js.map +7 -0
  184. package/dist/modules/staff/lib/time-tracking-ui/SupportRequestPanel.js +67 -0
  185. package/dist/modules/staff/lib/time-tracking-ui/SupportRequestPanel.js.map +7 -0
  186. package/dist/modules/staff/lib/time-tracking-ui/TaskBoardScreen.js +3 -1
  187. package/dist/modules/staff/lib/time-tracking-ui/TaskBoardScreen.js.map +2 -2
  188. package/dist/modules/staff/lib/time-tracking-ui/TaskCommentThread.js +80 -17
  189. package/dist/modules/staff/lib/time-tracking-ui/TaskCommentThread.js.map +2 -2
  190. package/dist/modules/staff/lib/time-tracking-ui/TaskDrawer.js +230 -41
  191. package/dist/modules/staff/lib/time-tracking-ui/TaskDrawer.js.map +2 -2
  192. package/dist/modules/staff/lib/time-tracking-ui/boardDirectory.js +21 -1
  193. package/dist/modules/staff/lib/time-tracking-ui/boardDirectory.js.map +2 -2
  194. package/dist/modules/staff/lib/time-tracking-ui/boardFilters.js +16 -4
  195. package/dist/modules/staff/lib/time-tracking-ui/boardFilters.js.map +2 -2
  196. package/dist/modules/staff/lib/time-tracking-ui/kanbanBoardData.js +3 -1
  197. package/dist/modules/staff/lib/time-tracking-ui/kanbanBoardData.js.map +2 -2
  198. package/dist/modules/staff/lib/time-tracking-ui/projectDefaultAssignee.js +68 -0
  199. package/dist/modules/staff/lib/time-tracking-ui/projectDefaultAssignee.js.map +7 -0
  200. package/dist/modules/staff/lib/time-tracking-ui/settingsTabs.js +7 -0
  201. package/dist/modules/staff/lib/time-tracking-ui/settingsTabs.js.map +7 -0
  202. package/dist/modules/staff/lib/time-tracking-ui/supportTicketData.js +50 -0
  203. package/dist/modules/staff/lib/time-tracking-ui/supportTicketData.js.map +7 -0
  204. package/dist/modules/staff/lib/time-tracking-ui/taskDrawerData.js +13 -1
  205. package/dist/modules/staff/lib/time-tracking-ui/taskDrawerData.js.map +2 -2
  206. package/dist/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.js +36 -2
  207. package/dist/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.js.map +2 -2
  208. package/dist/modules/staff/migrations/Migration20261009180543_staff.js +42 -0
  209. package/dist/modules/staff/migrations/Migration20261009180543_staff.js.map +7 -0
  210. package/dist/modules/staff/notifications.js +92 -0
  211. package/dist/modules/staff/notifications.js.map +2 -2
  212. package/dist/modules/staff/setup.js +6 -2
  213. package/dist/modules/staff/setup.js.map +2 -2
  214. package/dist/modules/staff/subscribers/support-portal-comment-created.js +26 -0
  215. package/dist/modules/staff/subscribers/support-portal-comment-created.js.map +7 -0
  216. package/dist/modules/staff/subscribers/support-portal-comment-deleted.js +26 -0
  217. package/dist/modules/staff/subscribers/support-portal-comment-deleted.js.map +7 -0
  218. package/dist/modules/staff/subscribers/support-portal-comment-updated.js +26 -0
  219. package/dist/modules/staff/subscribers/support-portal-comment-updated.js.map +7 -0
  220. package/dist/modules/staff/subscribers/support-portal-status-column-updated.js +24 -0
  221. package/dist/modules/staff/subscribers/support-portal-status-column-updated.js.map +7 -0
  222. package/dist/modules/staff/subscribers/support-portal-task-status-changed.js +25 -0
  223. package/dist/modules/staff/subscribers/support-portal-task-status-changed.js.map +7 -0
  224. package/dist/modules/staff/subscribers/support-portal-task-updated.js +25 -0
  225. package/dist/modules/staff/subscribers/support-portal-task-updated.js.map +7 -0
  226. package/dist/modules/staff/subscribers/support-ticket-created-notification.js +25 -0
  227. package/dist/modules/staff/subscribers/support-ticket-created-notification.js.map +7 -0
  228. package/dist/modules/staff/subscribers/support-ticket-customer-replied-notification.js +25 -0
  229. package/dist/modules/staff/subscribers/support-ticket-customer-replied-notification.js.map +7 -0
  230. package/dist/modules/staff/subscribers/support-ticket-unnotified-clear.js +26 -0
  231. package/dist/modules/staff/subscribers/support-ticket-unnotified-clear.js.map +7 -0
  232. package/dist/modules/staff/subscribers/time-task-assignee-notification.js +83 -0
  233. package/dist/modules/staff/subscribers/time-task-assignee-notification.js.map +7 -0
  234. package/dist/modules/staff/widgets/injection/portal-support-tickets/widget.client.js +112 -0
  235. package/dist/modules/staff/widgets/injection/portal-support-tickets/widget.client.js.map +7 -0
  236. package/dist/modules/staff/widgets/injection/portal-support-tickets/widget.js +17 -0
  237. package/dist/modules/staff/widgets/injection/portal-support-tickets/widget.js.map +7 -0
  238. package/dist/modules/staff/widgets/injection/time-tracking-settings-support/widget.client.js +870 -0
  239. package/dist/modules/staff/widgets/injection/time-tracking-settings-support/widget.client.js.map +7 -0
  240. package/dist/modules/staff/widgets/injection/time-tracking-settings-support/widget.js +17 -0
  241. package/dist/modules/staff/widgets/injection/time-tracking-settings-support/widget.js.map +7 -0
  242. package/dist/modules/staff/widgets/injection-table.js +16 -1
  243. package/dist/modules/staff/widgets/injection-table.js.map +2 -2
  244. package/dist/modules/wms/components/backend/WmsLocationDetailPage.js +4 -18
  245. package/dist/modules/wms/components/backend/WmsLocationDetailPage.js.map +2 -2
  246. package/dist/modules/wms/components/backend/WmsLotDetailPage.js +4 -18
  247. package/dist/modules/wms/components/backend/WmsLotDetailPage.js.map +2 -2
  248. package/dist/modules/wms/components/backend/WmsSkuDetailPage.js +4 -18
  249. package/dist/modules/wms/components/backend/WmsSkuDetailPage.js.map +2 -2
  250. package/dist/modules/wms/lib/inventoryCsvExport.js +19 -0
  251. package/dist/modules/wms/lib/inventoryCsvExport.js.map +7 -0
  252. package/generated/entities/staff_support_routing_rule/index.ts +9 -0
  253. package/generated/entities/staff_support_ticket/index.ts +17 -0
  254. package/generated/entities/staff_time_project/index.ts +1 -0
  255. package/generated/entities/staff_time_project_member/index.ts +1 -0
  256. package/generated/entities/staff_time_task/index.ts +2 -0
  257. package/generated/entities/staff_time_task_comment/index.ts +4 -0
  258. package/generated/entities.ids.generated.ts +2 -0
  259. package/generated/entity-fields-registry.ts +38 -0
  260. package/package.json +8 -8
  261. package/src/helpers/integration/auth.ts +1 -1
  262. package/src/helpers/integration/communicationChannelsFixtures.ts +60 -2
  263. package/src/modules/catalog/api/bulk-delete/route.ts +12 -2
  264. package/src/modules/communication_channels/api/post/test-seed/route.ts +29 -12
  265. package/src/modules/communication_channels/lib/test-seed.ts +49 -10
  266. package/src/modules/messages/commands/messages.ts +8 -10
  267. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +2 -2
  268. package/src/modules/staff/AGENTS.md +22 -6
  269. package/src/modules/staff/api/portal/support-tickets/[id]/attachments/route.ts +327 -0
  270. package/src/modules/staff/api/portal/support-tickets/[id]/messages/route.ts +94 -0
  271. package/src/modules/staff/api/portal/support-tickets/[id]/route.ts +160 -0
  272. package/src/modules/staff/api/portal/support-tickets/options/route.ts +60 -0
  273. package/src/modules/staff/api/portal/support-tickets/route.ts +253 -0
  274. package/src/modules/staff/api/timesheets/settings/route.ts +7 -1
  275. package/src/modules/staff/api/timesheets/support-routing-rules/fallback-project/route.ts +131 -0
  276. package/src/modules/staff/api/timesheets/support-routing-rules/preview/route.ts +248 -0
  277. package/src/modules/staff/api/timesheets/support-routing-rules/route.ts +150 -0
  278. package/src/modules/staff/api/timesheets/support-tickets/options/route.ts +181 -0
  279. package/src/modules/staff/api/timesheets/tasks/[id]/claim/route.ts +31 -0
  280. package/src/modules/staff/api/timesheets/tasks/[id]/comments/route.ts +25 -7
  281. package/src/modules/staff/api/timesheets/tasks/[id]/release/route.ts +30 -0
  282. package/src/modules/staff/api/timesheets/tasks/route.ts +119 -67
  283. package/src/modules/staff/api/timesheets/time-projects/[id]/employees/route.ts +3 -0
  284. package/src/modules/staff/api/timesheets/time-projects/route.ts +3 -0
  285. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +1 -0
  286. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +67 -2
  287. package/src/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.ts +22 -3
  288. package/src/modules/staff/backend/staff/time-tracking/settings/page.tsx +405 -308
  289. package/src/modules/staff/backend/staff/time-tracking/support/page.meta.ts +14 -0
  290. package/src/modules/staff/backend/staff/time-tracking/support/page.tsx +428 -0
  291. package/src/modules/staff/cli.ts +28 -0
  292. package/src/modules/staff/commands/index.ts +3 -0
  293. package/src/modules/staff/commands/shared.ts +22 -1
  294. package/src/modules/staff/commands/support-routing-rules.ts +595 -0
  295. package/src/modules/staff/commands/support-tickets.ts +580 -0
  296. package/src/modules/staff/commands/timesheets-projects.ts +84 -13
  297. package/src/modules/staff/commands/timesheets-task-assignment.ts +319 -0
  298. package/src/modules/staff/commands/timesheets-task-comments.ts +70 -15
  299. package/src/modules/staff/commands/timesheets-tasks.ts +106 -15
  300. package/src/modules/staff/data/enrichers.ts +203 -0
  301. package/src/modules/staff/data/entities.ts +133 -2
  302. package/src/modules/staff/data/extensions.ts +9 -1
  303. package/src/modules/staff/data/validators.ts +36 -0
  304. package/src/modules/staff/encryption.ts +6 -0
  305. package/src/modules/staff/events.payloads.ts +87 -1
  306. package/src/modules/staff/events.ts +26 -0
  307. package/src/modules/staff/extension-points.ts +1 -0
  308. package/src/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.meta.ts +10 -0
  309. package/src/modules/staff/frontend/[orgSlug]/portal/support/[id]/page.tsx +553 -0
  310. package/src/modules/staff/frontend/[orgSlug]/portal/support/new/page.meta.ts +10 -0
  311. package/src/modules/staff/frontend/[orgSlug]/portal/support/new/page.tsx +346 -0
  312. package/src/modules/staff/frontend/[orgSlug]/portal/support/page.meta.ts +17 -0
  313. package/src/modules/staff/frontend/[orgSlug]/portal/support/page.tsx +253 -0
  314. package/src/modules/staff/i18n/de.json +291 -0
  315. package/src/modules/staff/i18n/en.json +291 -0
  316. package/src/modules/staff/i18n/es.json +291 -0
  317. package/src/modules/staff/i18n/ko.json +291 -0
  318. package/src/modules/staff/i18n/pl.json +291 -0
  319. package/src/modules/staff/index.ts +1 -1
  320. package/src/modules/staff/lib/crud.ts +2 -1
  321. package/src/modules/staff/lib/support/attachments.ts +43 -0
  322. package/src/modules/staff/lib/support/dictionaries.ts +120 -0
  323. package/src/modules/staff/lib/support/notifications.ts +138 -0
  324. package/src/modules/staff/lib/support/portalAttachments.ts +140 -0
  325. package/src/modules/staff/lib/support/portalBroadcast.ts +161 -0
  326. package/src/modules/staff/lib/support/portalRoute.ts +108 -0
  327. package/src/modules/staff/lib/support/portalStatus.ts +15 -0
  328. package/src/modules/staff/lib/support/portalTickets.ts +233 -0
  329. package/src/modules/staff/lib/support/routing.ts +117 -0
  330. package/src/modules/staff/lib/support/routingRuleDraft.ts +45 -0
  331. package/src/modules/staff/lib/support/seedDefaults.ts +151 -0
  332. package/src/modules/staff/lib/support/supportActivity.ts +39 -0
  333. package/src/modules/staff/lib/support-ui/PortalSupportNotLinked.tsx +20 -0
  334. package/src/modules/staff/lib/support-ui/portalAttachmentValidation.ts +125 -0
  335. package/src/modules/staff/lib/support-ui/portalPendingUploads.ts +49 -0
  336. package/src/modules/staff/lib/support-ui/portalSupport.ts +116 -0
  337. package/src/modules/staff/lib/time-tracking/access.ts +2 -62
  338. package/src/modules/staff/lib/time-tracking/listProjectAccess.ts +78 -0
  339. package/src/modules/staff/lib/time-tracking/portalRecipients.ts +173 -5
  340. package/src/modules/staff/lib/time-tracking/projectAssignment.ts +172 -0
  341. package/src/modules/staff/lib/time-tracking/settingKeys.ts +11 -0
  342. package/src/modules/staff/lib/time-tracking/settings.ts +46 -1
  343. package/src/modules/staff/lib/time-tracking/taskAssignmentRoute.ts +165 -0
  344. package/src/modules/staff/lib/time-tracking/workRecipients.ts +183 -0
  345. package/src/modules/staff/lib/time-tracking-ui/BoardFilterChips.tsx +36 -1
  346. package/src/modules/staff/lib/time-tracking-ui/KanbanBoard.tsx +30 -0
  347. package/src/modules/staff/lib/time-tracking-ui/KanbanCard.tsx +9 -0
  348. package/src/modules/staff/lib/time-tracking-ui/ProjectFormSections.tsx +185 -6
  349. package/src/modules/staff/lib/time-tracking-ui/SupportAttachmentsSection.tsx +188 -0
  350. package/src/modules/staff/lib/time-tracking-ui/SupportRequestPanel.tsx +102 -0
  351. package/src/modules/staff/lib/time-tracking-ui/TaskBoardScreen.tsx +2 -0
  352. package/src/modules/staff/lib/time-tracking-ui/TaskCommentThread.tsx +105 -22
  353. package/src/modules/staff/lib/time-tracking-ui/TaskDrawer.tsx +278 -43
  354. package/src/modules/staff/lib/time-tracking-ui/boardDirectory.ts +21 -0
  355. package/src/modules/staff/lib/time-tracking-ui/boardFilters.ts +16 -2
  356. package/src/modules/staff/lib/time-tracking-ui/kanbanBoardData.ts +4 -0
  357. package/src/modules/staff/lib/time-tracking-ui/projectDefaultAssignee.ts +117 -0
  358. package/src/modules/staff/lib/time-tracking-ui/settingsTabs.ts +17 -0
  359. package/src/modules/staff/lib/time-tracking-ui/supportTicketData.ts +71 -0
  360. package/src/modules/staff/lib/time-tracking-ui/taskDrawerData.ts +15 -0
  361. package/src/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.ts +48 -1
  362. package/src/modules/staff/migrations/.snapshot-open-mercato.json +714 -0
  363. package/src/modules/staff/migrations/Migration20261009180543_staff.ts +50 -0
  364. package/src/modules/staff/notifications.ts +92 -0
  365. package/src/modules/staff/setup.ts +6 -2
  366. package/src/modules/staff/subscribers/support-portal-comment-created.ts +35 -0
  367. package/src/modules/staff/subscribers/support-portal-comment-deleted.ts +35 -0
  368. package/src/modules/staff/subscribers/support-portal-comment-updated.ts +35 -0
  369. package/src/modules/staff/subscribers/support-portal-status-column-updated.ts +36 -0
  370. package/src/modules/staff/subscribers/support-portal-task-status-changed.ts +34 -0
  371. package/src/modules/staff/subscribers/support-portal-task-updated.ts +34 -0
  372. package/src/modules/staff/subscribers/support-ticket-created-notification.ts +27 -0
  373. package/src/modules/staff/subscribers/support-ticket-customer-replied-notification.ts +27 -0
  374. package/src/modules/staff/subscribers/support-ticket-unnotified-clear.ts +29 -0
  375. package/src/modules/staff/subscribers/time-task-assignee-notification.ts +102 -0
  376. package/src/modules/staff/widgets/injection/portal-support-tickets/widget.client.tsx +151 -0
  377. package/src/modules/staff/widgets/injection/portal-support-tickets/widget.ts +16 -0
  378. package/src/modules/staff/widgets/injection/time-tracking-settings-support/widget.client.tsx +1058 -0
  379. package/src/modules/staff/widgets/injection/time-tracking-settings-support/widget.ts +17 -0
  380. package/src/modules/staff/widgets/injection-table.ts +15 -0
  381. package/src/modules/wms/components/backend/WmsLocationDetailPage.tsx +4 -20
  382. package/src/modules/wms/components/backend/WmsLotDetailPage.tsx +4 -20
  383. package/src/modules/wms/components/backend/WmsSkuDetailPage.tsx +4 -19
  384. package/src/modules/wms/lib/inventoryCsvExport.ts +18 -0
@@ -0,0 +1,125 @@
1
+ import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
2
+
3
+ export const DEFAULT_ATTACHMENT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
4
+
5
+ export const ATTACHMENT_ACCEPT_TYPES = [
6
+ '.avif',
7
+ '.bmp',
8
+ '.csv',
9
+ '.docx',
10
+ '.gif',
11
+ '.jpeg',
12
+ '.jpg',
13
+ '.json',
14
+ '.md',
15
+ '.pdf',
16
+ '.png',
17
+ '.pptx',
18
+ '.txt',
19
+ '.webp',
20
+ '.xlsx',
21
+ '.zip',
22
+ 'application/json',
23
+ 'application/pdf',
24
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
25
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
26
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
27
+ 'application/zip',
28
+ 'image/avif',
29
+ 'image/bmp',
30
+ 'image/gif',
31
+ 'image/jpeg',
32
+ 'image/png',
33
+ 'image/webp',
34
+ 'text/csv',
35
+ 'text/markdown',
36
+ 'text/plain',
37
+ ].join(',')
38
+
39
+ const BLOCKED_ATTACHMENT_MIME_TYPES = new Set([
40
+ 'application/xhtml+xml',
41
+ 'application/xml',
42
+ 'image/svg+xml',
43
+ 'text/html',
44
+ 'text/xml',
45
+ ])
46
+
47
+ const ACTIVE_CONTENT_ATTACHMENT_EXTENSIONS = new Set([
48
+ 'htm',
49
+ 'html',
50
+ 'svg',
51
+ 'xhtml',
52
+ 'xml',
53
+ ])
54
+
55
+ const DANGEROUS_EXECUTABLE_EXTENSIONS = new Set([
56
+ 'app',
57
+ 'apk',
58
+ 'bat',
59
+ 'cmd',
60
+ 'com',
61
+ 'dll',
62
+ 'exe',
63
+ 'hta',
64
+ 'htm',
65
+ 'html',
66
+ 'jar',
67
+ 'js',
68
+ 'jse',
69
+ 'lnk',
70
+ 'msi',
71
+ 'pif',
72
+ 'ps1',
73
+ 'psm1',
74
+ 'reg',
75
+ 'scr',
76
+ 'sh',
77
+ 'vbe',
78
+ 'vbs',
79
+ 'wsf',
80
+ 'wsh',
81
+ ])
82
+
83
+ function getFileExtensionSegments(fileName: string): string[] {
84
+ const parts = fileName.trim().split('.').filter(Boolean)
85
+ if (parts.length < 2) return []
86
+ return parts.slice(1).map((part) => part.toLowerCase().replace(/[^a-z0-9]/g, '')).filter(Boolean)
87
+ }
88
+
89
+ async function fileLooksLikeActiveContent(file: File): Promise<boolean> {
90
+ try {
91
+ const sniff = (await file.slice(0, 512).text()).trimStart().toLowerCase()
92
+ return sniff.startsWith('<svg') || sniff.startsWith('<?xml') || sniff.startsWith('<!doctype html') || sniff.startsWith('<html')
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ export function resolvePortalMaxUploadBytes(serverMaxBytes: number | null | undefined): number {
99
+ return typeof serverMaxBytes === 'number' && Number.isFinite(serverMaxBytes) && serverMaxBytes > 0
100
+ ? serverMaxBytes
101
+ : DEFAULT_ATTACHMENT_MAX_UPLOAD_BYTES
102
+ }
103
+
104
+ export async function validateAttachmentFile(
105
+ file: File,
106
+ t: TranslateFn,
107
+ maxBytes: number = DEFAULT_ATTACHMENT_MAX_UPLOAD_BYTES,
108
+ ): Promise<string | null> {
109
+ if (file.size > maxBytes) {
110
+ return t('attachments.errors.maxUploadSize')
111
+ }
112
+ const extensionSegments = getFileExtensionSegments(file.name)
113
+ const mimeType = file.type.trim().toLowerCase()
114
+ if (extensionSegments.some((extension) => DANGEROUS_EXECUTABLE_EXTENSIONS.has(extension))) {
115
+ return t('attachments.errors.dangerousExecutable')
116
+ }
117
+ if (
118
+ extensionSegments.some((extension) => ACTIVE_CONTENT_ATTACHMENT_EXTENSIONS.has(extension)) ||
119
+ (mimeType && BLOCKED_ATTACHMENT_MIME_TYPES.has(mimeType)) ||
120
+ await fileLooksLikeActiveContent(file)
121
+ ) {
122
+ return t('attachments.errors.activeContentBlocked')
123
+ }
124
+ return null
125
+ }
@@ -0,0 +1,49 @@
1
+ import { failedUploadsStorageKey } from './portalSupport'
2
+
3
+ /**
4
+ * Files that failed to upload while filing a ticket. The names survive a reload in
5
+ * `sessionStorage`; the `File` objects themselves can only live in memory, so a retry
6
+ * is possible for files picked in this browser session and a re-attach otherwise.
7
+ */
8
+ const retryableFilesByTicket = new Map<string, Map<string, File>>()
9
+
10
+ export function rememberFailedUploads(ticketId: string, files: readonly File[]): void {
11
+ if (!files.length) return
12
+ const current = retryableFilesByTicket.get(ticketId) ?? new Map<string, File>()
13
+ for (const file of files) current.set(file.name, file)
14
+ retryableFilesByTicket.set(ticketId, current)
15
+ writeFailedUploadNames(ticketId, [...new Set([...readFailedUploadNames(ticketId), ...files.map((file) => file.name)])])
16
+ }
17
+
18
+ export function getRetryableUpload(ticketId: string, fileName: string): File | null {
19
+ return retryableFilesByTicket.get(ticketId)?.get(fileName) ?? null
20
+ }
21
+
22
+ export function forgetFailedUpload(ticketId: string, fileName: string): string[] {
23
+ const files = retryableFilesByTicket.get(ticketId)
24
+ files?.delete(fileName)
25
+ if (files && files.size === 0) retryableFilesByTicket.delete(ticketId)
26
+ const remaining = readFailedUploadNames(ticketId).filter((name) => name !== fileName)
27
+ writeFailedUploadNames(ticketId, remaining)
28
+ return remaining
29
+ }
30
+
31
+ export function readFailedUploadNames(ticketId: string): string[] {
32
+ try {
33
+ const raw = window.sessionStorage.getItem(failedUploadsStorageKey(ticketId))
34
+ if (!raw) return []
35
+ const parsed: unknown = JSON.parse(raw)
36
+ return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : []
37
+ } catch {
38
+ return []
39
+ }
40
+ }
41
+
42
+ function writeFailedUploadNames(ticketId: string, names: readonly string[]): void {
43
+ try {
44
+ if (names.length) window.sessionStorage.setItem(failedUploadsStorageKey(ticketId), JSON.stringify(names))
45
+ else window.sessionStorage.removeItem(failedUploadsStorageKey(ticketId))
46
+ } catch {
47
+ return
48
+ }
49
+ }
@@ -0,0 +1,116 @@
1
+ import type { StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'
2
+ import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
3
+
4
+ export type PortalSupportStatus = 'received' | 'in_progress' | 'resolved'
5
+
6
+ export type PortalSupportTicketListItem = {
7
+ id: string
8
+ supportReference: string
9
+ subject: string
10
+ category: { value: string; label: string }
11
+ portalStatus: PortalSupportStatus
12
+ createdAt: string | null
13
+ updatedAt: string | null
14
+ lastActivityAt: string | null
15
+ }
16
+
17
+ export type PortalSupportTicketList = {
18
+ items?: PortalSupportTicketListItem[]
19
+ total?: number
20
+ page?: number
21
+ pageSize?: number
22
+ totalPages?: number
23
+ }
24
+
25
+ export type PortalSupportOptions = {
26
+ categories?: Array<{ value: string; label: string }>
27
+ canCreate?: boolean
28
+ maxUploadBytes?: number | null
29
+ }
30
+
31
+ export type PortalSupportMessage = {
32
+ id: string
33
+ authorKind: 'staff' | 'customer'
34
+ authorName: string | null
35
+ isSelf?: boolean
36
+ body: string
37
+ createdAt: string | null
38
+ }
39
+
40
+ export type PortalSupportTicketDetail = {
41
+ id: string
42
+ supportReference: string
43
+ subject: string
44
+ description: string
45
+ category: { value: string; label: string }
46
+ portalStatus: PortalSupportStatus
47
+ createdAt: string | null
48
+ updatedAt: string | null
49
+ canReply: boolean
50
+ requester?: { name: string | null; isSelf: boolean }
51
+ messages: PortalSupportMessage[]
52
+ attachments?: PortalSupportAttachment[]
53
+ }
54
+
55
+ export type PortalSupportAttachment = {
56
+ id: string
57
+ fileName: string
58
+ fileSize: number
59
+ mimeType: string | null
60
+ downloadUrl: string
61
+ uploadedByCustomer: boolean
62
+ canDelete: boolean
63
+ createdAt: string | null
64
+ }
65
+
66
+ export const PORTAL_SUPPORT_UPDATED_EVENT = 'staff.support_ticket.portal_updated'
67
+
68
+ export const PORTAL_SUPPORT_NOT_LINKED_CODE = 'customer_account_not_linked'
69
+
70
+ /**
71
+ * A portal account without a company link can never see or file tickets, so the
72
+ * support screens explain that instead of offering a retry that cannot succeed.
73
+ */
74
+ export function isPortalSupportAccountNotLinked(call: { status: number; result?: unknown }): boolean {
75
+ if (call.status !== 403 || !call.result || typeof call.result !== 'object') return false
76
+ return (call.result as { code?: unknown }).code === PORTAL_SUPPORT_NOT_LINKED_CODE
77
+ }
78
+
79
+ export const PORTAL_SUPPORT_STATUS_VARIANTS: Record<PortalSupportStatus, StatusBadgeVariant> = {
80
+ received: 'info',
81
+ in_progress: 'warning',
82
+ resolved: 'success',
83
+ }
84
+
85
+ export function portalSupportStatusLabel(t: TranslateFn, status: PortalSupportStatus): string {
86
+ if (status === 'resolved') return t('staff.portal.support.status.resolved', 'Resolved')
87
+ if (status === 'in_progress') return t('staff.portal.support.status.inProgress', 'In progress')
88
+ return t('staff.portal.support.status.received', 'Received')
89
+ }
90
+
91
+ export function formatPortalDate(value: string | null | undefined, locale?: string): string {
92
+ if (!value) return ''
93
+ const date = new Date(value)
94
+ if (Number.isNaN(date.getTime())) return ''
95
+ return date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' })
96
+ }
97
+
98
+ export function formatFileSize(t: TranslateFn, bytes: number): string {
99
+ if (!Number.isFinite(bytes) || bytes <= 0) return t('staff.portal.support.fileSize.bytes', '{size} B', { size: 0 })
100
+ if (bytes < 1024) return t('staff.portal.support.fileSize.bytes', '{size} B', { size: bytes })
101
+ if (bytes < 1024 * 1024) {
102
+ return t('staff.portal.support.fileSize.kilobytes', '{size} KB', { size: (bytes / 1024).toFixed(1) })
103
+ }
104
+ return t('staff.portal.support.fileSize.megabytes', '{size} MB', { size: (bytes / (1024 * 1024)).toFixed(1) })
105
+ }
106
+
107
+ export function failedUploadsStorageKey(ticketId: string): string {
108
+ return `om:staff:support:failed-uploads:${ticketId}`
109
+ }
110
+
111
+ export function readPortalTicketIdFromEvent(event: { payload?: Record<string, unknown> } | null | undefined): string | null {
112
+ const payload = event?.payload
113
+ if (!payload || typeof payload !== 'object') return null
114
+ const candidate = payload.ticketId ?? payload.id
115
+ return typeof candidate === 'string' ? candidate : null
116
+ }
@@ -2,7 +2,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
3
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
4
  import { StaffTeamMember, StaffTimeProjectMember } from '../../data/entities'
5
- import { DEFAULT_ASSIGNMENT_GRACE_DAYS, MAX_ASSIGNMENT_GRACE_DAYS } from './settings'
5
+ import { isWithinAssignmentWindow, normalizeAssignmentGraceDays, toDayIndex, toEntryDayIndex } from './projectAssignment'
6
6
 
7
7
  export const MANAGE_PROJECTS_FEATURE = 'staff.timesheets.projects.manage'
8
8
 
@@ -47,67 +47,7 @@ export type ProjectAccess = {
47
47
 
48
48
  const DENIED: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }
49
49
 
50
- const MS_PER_DAY = 86_400_000
51
- const ISO_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/
52
-
53
- export function normalizeAssignmentGraceDays(value: unknown): number {
54
- if (typeof value !== 'number' || !Number.isInteger(value)) return DEFAULT_ASSIGNMENT_GRACE_DAYS
55
- if (value < 0 || value > MAX_ASSIGNMENT_GRACE_DAYS) return DEFAULT_ASSIGNMENT_GRACE_DAYS
56
- return value
57
- }
58
-
59
- /**
60
- * `assigned_start_date` / `assigned_end_date` are `date` columns, so both bounds
61
- * are compared as whole days rather than timestamps. Date instances are read
62
- * through their local calendar fields because the postgres driver materialises a
63
- * `date` as local midnight, and "today" likewise means the server's local day.
64
- */
65
- function toDayIndex(value: unknown): number | null {
66
- if (value == null) return null
67
- if (value instanceof Date) {
68
- if (Number.isNaN(value.getTime())) return null
69
- return Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / MS_PER_DAY
70
- }
71
- if (typeof value === 'string') {
72
- const match = ISO_DATE_PREFIX.exec(value.trim())
73
- if (!match) return null
74
- return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) / MS_PER_DAY
75
- }
76
- return null
77
- }
78
-
79
- /**
80
- * An entry date is a calendar day the client sent as `YYYY-MM-DD`, which
81
- * `z.coerce.date()` turns into UTC midnight — so a Date is read through its UTC
82
- * fields here, the same way the grid addresses a cell (D-8).
83
- */
84
- function toEntryDayIndex(value: unknown): number | null {
85
- if (value instanceof Date) {
86
- if (Number.isNaN(value.getTime())) return null
87
- return Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()) / MS_PER_DAY
88
- }
89
- return toDayIndex(value)
90
- }
91
-
92
- /**
93
- * A membership only grants access inside its assignment window: from
94
- * `assigned_start_date` until `assigned_end_date` plus the tenant's grace days.
95
- * A null end date is open-ended and never expires.
96
- */
97
- export function isWithinAssignmentWindow(
98
- membership: Pick<StaffTimeProjectMember, 'assignedStartDate' | 'assignedEndDate'>,
99
- todayIndex: number,
100
- graceDays: number,
101
- ): boolean {
102
- if (membership.assignedStartDate != null) {
103
- const startIndex = toDayIndex(membership.assignedStartDate)
104
- if (startIndex === null || todayIndex < startIndex) return false
105
- }
106
- if (membership.assignedEndDate == null) return true
107
- const endIndex = toDayIndex(membership.assignedEndDate)
108
- if (endIndex === null) return false
109
- return todayIndex <= endIndex + graceDays
110
- }
50
+ export { isWithinAssignmentWindow, normalizeAssignmentGraceDays, toDayIndex }
111
51
 
112
52
  export async function resolveProjectAccess(ctx: ProjectAccessContext): Promise<ProjectAccess> {
113
53
  const tenantId = ctx.tenantId ?? null
@@ -0,0 +1,78 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
3
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
5
+ import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
6
+ import { resolveFeatureAccess } from './featureAccess'
7
+ import { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from './access'
8
+ import { readTimeTrackingSettings } from './settings'
9
+
10
+ const logger = createLogger('staff').child({ component: 'lib/time-tracking/listProjectAccess' })
11
+
12
+ const DENIED_ACCESS: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }
13
+
14
+ type ContainerLike = { resolve: (name: string) => unknown }
15
+
16
+ type ListAccessCtx = Pick<CrudCtx, 'container' | 'auth' | 'selectedOrganizationId' | 'request'>
17
+
18
+ /** One access decision per request, shared by every filter that needs it. */
19
+ const accessByRequest = new WeakMap<Request, Promise<ProjectAccess>>()
20
+
21
+ async function resolveAssignmentGraceDays(container: ContainerLike, tenantId: string): Promise<number | null> {
22
+ try {
23
+ const configService = container.resolve('moduleConfigService') as ModuleConfigService
24
+ const settings = await readTimeTrackingSettings(configService, { tenantId })
25
+ return settings.access.assignmentGraceDays
26
+ } catch {
27
+ // Fail safe to the documented default rather than widening the window.
28
+ return null
29
+ }
30
+ }
31
+
32
+ async function loadProjectAccess(
33
+ container: ContainerLike,
34
+ userId: string | null,
35
+ tenantId: string | null,
36
+ organizationId: string | null,
37
+ ): Promise<ProjectAccess> {
38
+ if (!tenantId || !organizationId) return { ...DENIED_ACCESS }
39
+ const scope = { tenantId, organizationId }
40
+ try {
41
+ // One lookup, one authority, and a failure that says so. The previous
42
+ // `catch → []` could not tell "denied" from "could not ask", so an RBAC
43
+ // hiccup silently demoted a manager to their own memberships.
44
+ const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope)
45
+ const em = container.resolve('em') as EntityManager
46
+ return await resolveProjectAccess({
47
+ em: em.fork(),
48
+ userId,
49
+ tenantId,
50
+ organizationId,
51
+ canManageAll: access.allowed,
52
+ userFeatures: access.grantedFeatures,
53
+ assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId),
54
+ })
55
+ } catch (err) {
56
+ logger.error('staff.timesheets.tasks access resolution failed', { err })
57
+ getTelemetryRuntime()?.reportError(err, { module: 'staff', code: 'staff.task_access_resolution_failed' })
58
+ return { ...DENIED_ACCESS }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The project-access decision of the tasks list, memoised per request and failing
64
+ * closed. Shared by the tasks list route and the task action routes (status move,
65
+ * claim, release) so a task is only ever reachable through one access rule.
66
+ */
67
+ export async function resolveListProjectAccess(ctx: ListAccessCtx): Promise<ProjectAccess> {
68
+ const tenantId = ctx.auth?.tenantId ?? null
69
+ const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
70
+ const load = () => loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId)
71
+ const request = ctx.request
72
+ if (!request) return load()
73
+ const cached = accessByRequest.get(request)
74
+ if (cached) return cached
75
+ const pending = load()
76
+ accessByRequest.set(request, pending)
77
+ return pending
78
+ }
@@ -1,5 +1,8 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { TableNotFoundException } from '@mikro-orm/core'
2
3
  import { createLogger } from '@open-mercato/shared/lib/logger'
4
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
5
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
6
 
4
7
  const logger = createLogger('staff').child({ component: 'time-tracking/portal-recipients' })
5
8
 
@@ -8,12 +11,20 @@ const logger = createLogger('staff').child({ component: 'time-tracking/portal-re
8
11
  * column **names** rather than imports.
9
12
  *
10
13
  * Staff is slated for extraction into `@open-mercato/staff`, so it may not take a
11
- * static dependency on `customer_accounts`; `requires` in `index.ts` stays
12
- * `['planner', 'resources']`. A Kysely query against the string
14
+ * static dependency on `customer_accounts`; `requires` in `index.ts` never gains it.
15
+ * A Kysely query against the string
13
16
  * `'customer_users'` reads the mapping without an import edge, exactly the way
14
17
  * `warranty_claims/commands/claims.ts` does — and if the table is absent because
15
- * the module is not installed, the query throws and this returns an empty list,
16
- * which every caller treats as "no portal audience".
18
+ * the module is not installed, the query throws "relation does not exist" and the
19
+ * audience is empty, which every caller treats as "no portal audience".
20
+ *
21
+ * Each helper comes in two flavours. The `...Strict` variant returns `[]` only for
22
+ * an expected empty audience (no rows, or the `customer_accounts` tables absent)
23
+ * and rethrows any other query failure, so a caller that records "the portal was
24
+ * told" can retry instead of recording a delivery that never happened. The lenient
25
+ * variant keeps the historical swallow-and-return-`[]` contract and reports the
26
+ * unexpected failure to telemetry. Both fail closed: neither ever returns an
27
+ * unfiltered audience.
17
28
  *
18
29
  * `customer_users.customer_entity_id` is the FK into `customers:customer_entity`,
19
30
  * which is the same id `staff_time_reports.customer_id` holds (EP-44 declares that
@@ -42,11 +53,30 @@ export type PortalRecipientScope = {
42
53
  /** A broadcast fan-out, not a mailing list — a customer with more portal users than this gets a truncated audience rather than an unbounded query. */
43
54
  export const MAX_PORTAL_RECIPIENTS = 100
44
55
 
56
+ const UNDEFINED_TABLE_SQLSTATE = '42P01'
57
+
45
58
  function isNonEmpty(value: unknown): value is string {
46
59
  return typeof value === 'string' && value.trim().length > 0
47
60
  }
48
61
 
49
- export async function resolvePortalRecipientUserIds(
62
+ /** The `customer_accounts` tables are not installed: an expected "no portal audience", not a failure. */
63
+ export function isPortalTableMissingError(error: unknown): boolean {
64
+ let current: unknown = error
65
+ for (let depth = 0; depth < 4 && typeof current === 'object' && current !== null; depth += 1) {
66
+ if (current instanceof TableNotFoundException) return true
67
+ const candidate = current as { code?: unknown; cause?: unknown }
68
+ if (candidate.code === UNDEFINED_TABLE_SQLSTATE) return true
69
+ current = candidate.cause
70
+ }
71
+ return false
72
+ }
73
+
74
+ function reportSwallowedRecipientError(err: unknown, code: string): void {
75
+ if (isPortalTableMissingError(err)) return
76
+ getTelemetryRuntime()?.reportError(err, { module: 'staff', code })
77
+ }
78
+
79
+ export async function resolvePortalRecipientUserIdsStrict(
50
80
  em: EntityManager,
51
81
  scope: PortalRecipientScope,
52
82
  ): Promise<string[]> {
@@ -66,11 +96,149 @@ export async function resolvePortalRecipientUserIds(
66
96
  .limit(MAX_PORTAL_RECIPIENTS)
67
97
  .execute()
68
98
  return rows.map((row) => row.id).filter(isNonEmpty)
99
+ } catch (err) {
100
+ if (isPortalTableMissingError(err)) return []
101
+ throw err
102
+ }
103
+ }
104
+
105
+ export async function resolvePortalRecipientUserIds(
106
+ em: EntityManager,
107
+ scope: PortalRecipientScope,
108
+ ): Promise<string[]> {
109
+ try {
110
+ return await resolvePortalRecipientUserIdsStrict(em, scope)
69
111
  } catch (err) {
70
112
  logger.warn('Could not resolve portal recipients for a time-tracking customer', {
71
113
  customerId: scope.customerId,
72
114
  err,
73
115
  })
116
+ reportSwallowedRecipientError(err, 'staff.portal_recipients_resolve_failed')
117
+ return []
118
+ }
119
+ }
120
+
121
+ type CustomerAclDb = {
122
+ customer_user_acls: {
123
+ user_id: string
124
+ tenant_id: string
125
+ features_json: unknown
126
+ is_portal_admin: boolean
127
+ }
128
+ customer_user_roles: {
129
+ user_id: string
130
+ role_id: string
131
+ deleted_at: Date | null
132
+ }
133
+ customer_roles: {
134
+ id: string
135
+ tenant_id: string
136
+ organization_id: string
137
+ deleted_at: Date | null
138
+ }
139
+ customer_role_acls: {
140
+ role_id: string
141
+ tenant_id: string
142
+ features_json: unknown
143
+ is_portal_admin: boolean
144
+ }
145
+ }
146
+
147
+ type PortalAclGrant = { features: string[]; isPortalAdmin: boolean }
148
+
149
+ function readFeatureList(value: unknown): string[] {
150
+ let parsed: unknown = value
151
+ if (typeof value === 'string') {
152
+ try {
153
+ parsed = JSON.parse(value)
154
+ } catch {
155
+ return []
156
+ }
157
+ }
158
+ return Array.isArray(parsed) ? parsed.filter(isNonEmpty) : []
159
+ }
160
+
161
+ function mergeGrant(grants: Map<string, PortalAclGrant>, userId: string, featuresJson: unknown, isPortalAdmin: boolean): void {
162
+ const grant = grants.get(userId) ?? { features: [], isPortalAdmin: false }
163
+ grant.isPortalAdmin = grant.isPortalAdmin || isPortalAdmin === true
164
+ for (const feature of readFeatureList(featuresJson)) {
165
+ if (!grant.features.includes(feature)) grant.features.push(feature)
166
+ }
167
+ grants.set(userId, grant)
168
+ }
169
+
170
+ /**
171
+ * Narrows a portal audience to the customer users holding `requiredFeature`, resolved the
172
+ * way `customer_accounts`' `CustomerRbacService.loadAcl` does: a per-user ACL row replaces
173
+ * the user's role ACLs, a portal admin is unrestricted, and grants match wildcard-aware via
174
+ * `authorizeFeatures`. Read by table name for the same decoupling reason as above. The
175
+ * strict variant returns `[]` when the module is absent and rethrows any other failure;
176
+ * neither variant ever returns an unfiltered audience.
177
+ */
178
+ export async function filterPortalRecipientsByFeatureStrict(
179
+ em: EntityManager,
180
+ userIds: string[],
181
+ scope: { tenantId: string; organizationId: string },
182
+ requiredFeature: string,
183
+ ): Promise<string[]> {
184
+ const candidates = Array.from(new Set(userIds.filter(isNonEmpty)))
185
+ if (candidates.length === 0 || !isNonEmpty(scope.tenantId) || !isNonEmpty(scope.organizationId)) return []
186
+ try {
187
+ const db = em.getKysely<CustomerAclDb>()
188
+ const userAclRows = await db
189
+ .selectFrom('customer_user_acls')
190
+ .select(['user_id', 'features_json', 'is_portal_admin'])
191
+ .where('tenant_id', '=', scope.tenantId)
192
+ .where('user_id', 'in', candidates)
193
+ .execute()
194
+ const userGrants = new Map<string, PortalAclGrant>()
195
+ for (const row of userAclRows) mergeGrant(userGrants, row.user_id, row.features_json, row.is_portal_admin)
196
+ const roleCandidates = candidates.filter((userId) => !userGrants.has(userId))
197
+ const roleGrants = new Map<string, PortalAclGrant>()
198
+ if (roleCandidates.length > 0) {
199
+ const roleAclRows = await db
200
+ .selectFrom('customer_user_roles')
201
+ .innerJoin('customer_roles', 'customer_roles.id', 'customer_user_roles.role_id')
202
+ .innerJoin('customer_role_acls', 'customer_role_acls.role_id', 'customer_roles.id')
203
+ .select([
204
+ 'customer_user_roles.user_id as user_id',
205
+ 'customer_role_acls.features_json as features_json',
206
+ 'customer_role_acls.is_portal_admin as is_portal_admin',
207
+ ])
208
+ .where('customer_user_roles.user_id', 'in', roleCandidates)
209
+ .where('customer_user_roles.deleted_at', 'is', null)
210
+ .where('customer_roles.tenant_id', '=', scope.tenantId)
211
+ .where('customer_roles.organization_id', '=', scope.organizationId)
212
+ .where('customer_roles.deleted_at', 'is', null)
213
+ .where('customer_role_acls.tenant_id', '=', scope.tenantId)
214
+ .execute()
215
+ for (const row of roleAclRows) mergeGrant(roleGrants, row.user_id, row.features_json, row.is_portal_admin)
216
+ }
217
+ return candidates.filter((userId) => {
218
+ const grant = userGrants.get(userId) ?? roleGrants.get(userId)
219
+ if (!grant) return false
220
+ return authorizeFeatures([requiredFeature], { grantedFeatures: grant.features, unrestricted: grant.isPortalAdmin })
221
+ })
222
+ } catch (err) {
223
+ if (isPortalTableMissingError(err)) return []
224
+ throw err
225
+ }
226
+ }
227
+
228
+ export async function filterPortalRecipientsByFeature(
229
+ em: EntityManager,
230
+ userIds: string[],
231
+ scope: { tenantId: string; organizationId: string },
232
+ requiredFeature: string,
233
+ ): Promise<string[]> {
234
+ try {
235
+ return await filterPortalRecipientsByFeatureStrict(em, userIds, scope, requiredFeature)
236
+ } catch (err) {
237
+ logger.warn('Could not resolve portal recipient features; treating the audience as empty', {
238
+ feature: requiredFeature,
239
+ err,
240
+ })
241
+ reportSwallowedRecipientError(err, 'staff.portal_recipients_feature_filter_failed')
74
242
  return []
75
243
  }
76
244
  }