@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
@@ -1,46 +1,9 @@
1
1
  import { authorizeFeatures } from "@open-mercato/shared/security/featurePolicy";
2
2
  import { findWithDecryption, findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
3
  import { StaffTeamMember, StaffTimeProjectMember } from "../../data/entities.js";
4
- import { DEFAULT_ASSIGNMENT_GRACE_DAYS, MAX_ASSIGNMENT_GRACE_DAYS } from "./settings.js";
4
+ import { isWithinAssignmentWindow, normalizeAssignmentGraceDays, toDayIndex, toEntryDayIndex } from "./projectAssignment.js";
5
5
  const MANAGE_PROJECTS_FEATURE = "staff.timesheets.projects.manage";
6
6
  const DENIED = { canManageAll: false, projectIds: [], staffMemberId: null };
7
- const MS_PER_DAY = 864e5;
8
- const ISO_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/;
9
- function normalizeAssignmentGraceDays(value) {
10
- if (typeof value !== "number" || !Number.isInteger(value)) return DEFAULT_ASSIGNMENT_GRACE_DAYS;
11
- if (value < 0 || value > MAX_ASSIGNMENT_GRACE_DAYS) return DEFAULT_ASSIGNMENT_GRACE_DAYS;
12
- return value;
13
- }
14
- function toDayIndex(value) {
15
- if (value == null) return null;
16
- if (value instanceof Date) {
17
- if (Number.isNaN(value.getTime())) return null;
18
- return Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / MS_PER_DAY;
19
- }
20
- if (typeof value === "string") {
21
- const match = ISO_DATE_PREFIX.exec(value.trim());
22
- if (!match) return null;
23
- return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) / MS_PER_DAY;
24
- }
25
- return null;
26
- }
27
- function toEntryDayIndex(value) {
28
- if (value instanceof Date) {
29
- if (Number.isNaN(value.getTime())) return null;
30
- return Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()) / MS_PER_DAY;
31
- }
32
- return toDayIndex(value);
33
- }
34
- function isWithinAssignmentWindow(membership, todayIndex, graceDays) {
35
- if (membership.assignedStartDate != null) {
36
- const startIndex = toDayIndex(membership.assignedStartDate);
37
- if (startIndex === null || todayIndex < startIndex) return false;
38
- }
39
- if (membership.assignedEndDate == null) return true;
40
- const endIndex = toDayIndex(membership.assignedEndDate);
41
- if (endIndex === null) return false;
42
- return todayIndex <= endIndex + graceDays;
43
- }
44
7
  async function resolveProjectAccess(ctx) {
45
8
  const tenantId = ctx.tenantId ?? null;
46
9
  const organizationId = ctx.organizationId ?? null;
@@ -116,6 +79,7 @@ export {
116
79
  isProjectAccessibleOnDate,
117
80
  isWithinAssignmentWindow,
118
81
  normalizeAssignmentGraceDays,
119
- resolveProjectAccess
82
+ resolveProjectAccess,
83
+ toDayIndex
120
84
  };
121
85
  //# sourceMappingURL=access.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/staff/lib/time-tracking/access.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { StaffTeamMember, StaffTimeProjectMember } from '../../data/entities'\nimport { DEFAULT_ASSIGNMENT_GRACE_DAYS, MAX_ASSIGNMENT_GRACE_DAYS } from './settings'\n\nexport const MANAGE_PROJECTS_FEATURE = 'staff.timesheets.projects.manage'\n\nexport type ProjectAccessContext = {\n em: EntityManager\n userId?: string | null\n tenantId?: string | null\n organizationId?: string | null\n userFeatures?: readonly string[]\n /**\n * The manage-all decision, resolved by the caller through `resolveFeatureAccess`.\n * Preferred over `userFeatures`: a grant array cannot distinguish \"denied\" from\n * \"could not ask\", and the difference is a silently downgraded manager.\n */\n canManageAll?: boolean\n /**\n * Resolved `access.assignmentGraceDays` setting. Passed in rather than read\n * here so the resolver stays independent of `ModuleConfigService`; callers\n * that already read the tenant settings avoid a second lookup.\n */\n assignmentGraceDays?: number | null\n /** Injectable clock so the assignment window is testable; defaults to now. */\n now?: Date\n}\n\nexport type AssignmentWindow = {\n startIndex: number | null\n endIndex: number | null\n}\n\nexport type ProjectAccess = {\n canManageAll: boolean\n projectIds: string[]\n staffMemberId: string | null\n /**\n * The windows (day indexes, end already extended by the grace days) of the\n * memberships that granted each project in `projectIds`. Absent on a hand-built\n * access, which then places no bound on the entry date.\n */\n assignmentWindows?: Record<string, AssignmentWindow[]>\n}\n\nconst DENIED: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\nconst MS_PER_DAY = 86_400_000\nconst ISO_DATE_PREFIX = /^(\\d{4})-(\\d{2})-(\\d{2})/\n\nexport function normalizeAssignmentGraceDays(value: unknown): number {\n if (typeof value !== 'number' || !Number.isInteger(value)) return DEFAULT_ASSIGNMENT_GRACE_DAYS\n if (value < 0 || value > MAX_ASSIGNMENT_GRACE_DAYS) return DEFAULT_ASSIGNMENT_GRACE_DAYS\n return value\n}\n\n/**\n * `assigned_start_date` / `assigned_end_date` are `date` columns, so both bounds\n * are compared as whole days rather than timestamps. Date instances are read\n * through their local calendar fields because the postgres driver materialises a\n * `date` as local midnight, and \"today\" likewise means the server's local day.\n */\nfunction toDayIndex(value: unknown): number | null {\n if (value == null) return null\n if (value instanceof Date) {\n if (Number.isNaN(value.getTime())) return null\n return Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / MS_PER_DAY\n }\n if (typeof value === 'string') {\n const match = ISO_DATE_PREFIX.exec(value.trim())\n if (!match) return null\n return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) / MS_PER_DAY\n }\n return null\n}\n\n/**\n * An entry date is a calendar day the client sent as `YYYY-MM-DD`, which\n * `z.coerce.date()` turns into UTC midnight \u2014 so a Date is read through its UTC\n * fields here, the same way the grid addresses a cell (D-8).\n */\nfunction toEntryDayIndex(value: unknown): number | null {\n if (value instanceof Date) {\n if (Number.isNaN(value.getTime())) return null\n return Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()) / MS_PER_DAY\n }\n return toDayIndex(value)\n}\n\n/**\n * A membership only grants access inside its assignment window: from\n * `assigned_start_date` until `assigned_end_date` plus the tenant's grace days.\n * A null end date is open-ended and never expires.\n */\nexport function isWithinAssignmentWindow(\n membership: Pick<StaffTimeProjectMember, 'assignedStartDate' | 'assignedEndDate'>,\n todayIndex: number,\n graceDays: number,\n): boolean {\n if (membership.assignedStartDate != null) {\n const startIndex = toDayIndex(membership.assignedStartDate)\n if (startIndex === null || todayIndex < startIndex) return false\n }\n if (membership.assignedEndDate == null) return true\n const endIndex = toDayIndex(membership.assignedEndDate)\n if (endIndex === null) return false\n return todayIndex <= endIndex + graceDays\n}\n\nexport async function resolveProjectAccess(ctx: ProjectAccessContext): Promise<ProjectAccess> {\n const tenantId = ctx.tenantId ?? null\n const organizationId = ctx.organizationId ?? null\n const userId = ctx.userId ?? null\n const grantedFeatures = ctx.userFeatures ?? []\n\n // The decision is the caller's to make, through `resolveFeatureAccess`, which\n // asks the RBAC service once and fails closed loudly. Deriving it here from a\n // grant array is what let a failed lookup pass as \"no permission\": the array\n // came back empty, `canManageAll` went false, and a manager silently dropped to\n // their own memberships while the KPI endpoint \u2014 asking the service directly \u2014\n // kept reporting the full portfolio.\n //\n // The array path remains for callers that have not been migrated, but it cannot\n // distinguish \"denied\" from \"could not ask\", so it is deprecated.\n const canManageAll =\n ctx.canManageAll ??\n authorizeFeatures([MANAGE_PROJECTS_FEATURE], {\n grantedFeatures,\n scopeAllowed: Boolean(tenantId) && Boolean(organizationId),\n })\n\n if (!tenantId || !organizationId || !userId) {\n return canManageAll ? { ...DENIED, canManageAll } : { ...DENIED }\n }\n\n const scope = { tenantId, organizationId }\n const staffMember = await findOneWithDecryption(\n ctx.em,\n StaffTeamMember,\n { userId, tenantId, organizationId, deletedAt: null },\n {},\n scope,\n )\n const staffMemberId = staffMember?.id ?? null\n\n if (canManageAll) return { canManageAll: true, projectIds: [], staffMemberId }\n if (!staffMemberId) return { ...DENIED }\n\n const memberships = await findWithDecryption(\n ctx.em,\n StaffTimeProjectMember,\n { staffMemberId, tenantId, organizationId, status: 'active', deletedAt: null },\n {},\n scope,\n )\n\n const graceDays = normalizeAssignmentGraceDays(ctx.assignmentGraceDays)\n const todayIndex = toDayIndex(ctx.now ?? new Date())\n\n const projectIds: string[] = []\n const assignmentWindows: Record<string, AssignmentWindow[]> = {}\n for (const membership of memberships) {\n const projectId = membership.timeProjectId\n if (!projectId) continue\n if (todayIndex === null || !isWithinAssignmentWindow(membership, todayIndex, graceDays)) continue\n const endIndex = toDayIndex(membership.assignedEndDate)\n const window: AssignmentWindow = {\n startIndex: toDayIndex(membership.assignedStartDate),\n endIndex: endIndex === null ? null : endIndex + graceDays,\n }\n const windows = assignmentWindows[projectId]\n if (windows) {\n windows.push(window)\n continue\n }\n assignmentWindows[projectId] = [window]\n projectIds.push(projectId)\n }\n\n return { canManageAll: false, projectIds, staffMemberId, assignmentWindows }\n}\n\n/**\n * Whether the caller may write time dated `date` on `projectId`. On top of the\n * today-based access `resolveProjectAccess` grants (D-12), the entry's own date\n * must fall inside one of the windows of the memberships that granted it: from\n * `assigned_start_date` until `assigned_end_date` plus the grace days. An\n * unparseable date fails closed.\n */\nexport function isProjectAccessibleOnDate(\n access: ProjectAccess,\n projectId: string | null | undefined,\n date: Date | string | null | undefined,\n): boolean {\n if (access.canManageAll) return true\n if (!projectId || !access.projectIds.includes(projectId)) return false\n const windows = access.assignmentWindows?.[projectId]\n if (!windows) return true\n const dateIndex = toEntryDayIndex(date)\n if (dateIndex === null) return false\n return windows.some(\n (window) =>\n (window.startIndex === null || dateIndex >= window.startIndex) &&\n (window.endIndex === null || dateIndex <= window.endIndex),\n )\n}\n\nexport function assertProjectAccess(access: ProjectAccess, projectId?: string | null): boolean {\n if (access.canManageAll) return true\n if (!projectId) return false\n return access.projectIds.includes(projectId)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { StaffTeamMember, StaffTimeProjectMember } from '../../data/entities'\nimport { isWithinAssignmentWindow, normalizeAssignmentGraceDays, toDayIndex, toEntryDayIndex } from './projectAssignment'\n\nexport const MANAGE_PROJECTS_FEATURE = 'staff.timesheets.projects.manage'\n\nexport type ProjectAccessContext = {\n em: EntityManager\n userId?: string | null\n tenantId?: string | null\n organizationId?: string | null\n userFeatures?: readonly string[]\n /**\n * The manage-all decision, resolved by the caller through `resolveFeatureAccess`.\n * Preferred over `userFeatures`: a grant array cannot distinguish \"denied\" from\n * \"could not ask\", and the difference is a silently downgraded manager.\n */\n canManageAll?: boolean\n /**\n * Resolved `access.assignmentGraceDays` setting. Passed in rather than read\n * here so the resolver stays independent of `ModuleConfigService`; callers\n * that already read the tenant settings avoid a second lookup.\n */\n assignmentGraceDays?: number | null\n /** Injectable clock so the assignment window is testable; defaults to now. */\n now?: Date\n}\n\nexport type AssignmentWindow = {\n startIndex: number | null\n endIndex: number | null\n}\n\nexport type ProjectAccess = {\n canManageAll: boolean\n projectIds: string[]\n staffMemberId: string | null\n /**\n * The windows (day indexes, end already extended by the grace days) of the\n * memberships that granted each project in `projectIds`. Absent on a hand-built\n * access, which then places no bound on the entry date.\n */\n assignmentWindows?: Record<string, AssignmentWindow[]>\n}\n\nconst DENIED: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\nexport { isWithinAssignmentWindow, normalizeAssignmentGraceDays, toDayIndex }\n\nexport async function resolveProjectAccess(ctx: ProjectAccessContext): Promise<ProjectAccess> {\n const tenantId = ctx.tenantId ?? null\n const organizationId = ctx.organizationId ?? null\n const userId = ctx.userId ?? null\n const grantedFeatures = ctx.userFeatures ?? []\n\n // The decision is the caller's to make, through `resolveFeatureAccess`, which\n // asks the RBAC service once and fails closed loudly. Deriving it here from a\n // grant array is what let a failed lookup pass as \"no permission\": the array\n // came back empty, `canManageAll` went false, and a manager silently dropped to\n // their own memberships while the KPI endpoint \u2014 asking the service directly \u2014\n // kept reporting the full portfolio.\n //\n // The array path remains for callers that have not been migrated, but it cannot\n // distinguish \"denied\" from \"could not ask\", so it is deprecated.\n const canManageAll =\n ctx.canManageAll ??\n authorizeFeatures([MANAGE_PROJECTS_FEATURE], {\n grantedFeatures,\n scopeAllowed: Boolean(tenantId) && Boolean(organizationId),\n })\n\n if (!tenantId || !organizationId || !userId) {\n return canManageAll ? { ...DENIED, canManageAll } : { ...DENIED }\n }\n\n const scope = { tenantId, organizationId }\n const staffMember = await findOneWithDecryption(\n ctx.em,\n StaffTeamMember,\n { userId, tenantId, organizationId, deletedAt: null },\n {},\n scope,\n )\n const staffMemberId = staffMember?.id ?? null\n\n if (canManageAll) return { canManageAll: true, projectIds: [], staffMemberId }\n if (!staffMemberId) return { ...DENIED }\n\n const memberships = await findWithDecryption(\n ctx.em,\n StaffTimeProjectMember,\n { staffMemberId, tenantId, organizationId, status: 'active', deletedAt: null },\n {},\n scope,\n )\n\n const graceDays = normalizeAssignmentGraceDays(ctx.assignmentGraceDays)\n const todayIndex = toDayIndex(ctx.now ?? new Date())\n\n const projectIds: string[] = []\n const assignmentWindows: Record<string, AssignmentWindow[]> = {}\n for (const membership of memberships) {\n const projectId = membership.timeProjectId\n if (!projectId) continue\n if (todayIndex === null || !isWithinAssignmentWindow(membership, todayIndex, graceDays)) continue\n const endIndex = toDayIndex(membership.assignedEndDate)\n const window: AssignmentWindow = {\n startIndex: toDayIndex(membership.assignedStartDate),\n endIndex: endIndex === null ? null : endIndex + graceDays,\n }\n const windows = assignmentWindows[projectId]\n if (windows) {\n windows.push(window)\n continue\n }\n assignmentWindows[projectId] = [window]\n projectIds.push(projectId)\n }\n\n return { canManageAll: false, projectIds, staffMemberId, assignmentWindows }\n}\n\n/**\n * Whether the caller may write time dated `date` on `projectId`. On top of the\n * today-based access `resolveProjectAccess` grants (D-12), the entry's own date\n * must fall inside one of the windows of the memberships that granted it: from\n * `assigned_start_date` until `assigned_end_date` plus the grace days. An\n * unparseable date fails closed.\n */\nexport function isProjectAccessibleOnDate(\n access: ProjectAccess,\n projectId: string | null | undefined,\n date: Date | string | null | undefined,\n): boolean {\n if (access.canManageAll) return true\n if (!projectId || !access.projectIds.includes(projectId)) return false\n const windows = access.assignmentWindows?.[projectId]\n if (!windows) return true\n const dateIndex = toEntryDayIndex(date)\n if (dateIndex === null) return false\n return windows.some(\n (window) =>\n (window.startIndex === null || dateIndex >= window.startIndex) &&\n (window.endIndex === null || dateIndex <= window.endIndex),\n )\n}\n\nexport function assertProjectAccess(access: ProjectAccess, projectId?: string | null): boolean {\n if (access.canManageAll) return true\n if (!projectId) return false\n return access.projectIds.includes(projectId)\n}\n"],
5
+ "mappings": "AACA,SAAS,yBAAyB;AAClC,SAAS,oBAAoB,6BAA6B;AAC1D,SAAS,iBAAiB,8BAA8B;AACxD,SAAS,0BAA0B,8BAA8B,YAAY,uBAAuB;AAE7F,MAAM,0BAA0B;AAyCvC,MAAM,SAAwB,EAAE,cAAc,OAAO,YAAY,CAAC,GAAG,eAAe,KAAK;AAIzF,eAAsB,qBAAqB,KAAmD;AAC5F,QAAM,WAAW,IAAI,YAAY;AACjC,QAAM,iBAAiB,IAAI,kBAAkB;AAC7C,QAAM,SAAS,IAAI,UAAU;AAC7B,QAAM,kBAAkB,IAAI,gBAAgB,CAAC;AAW7C,QAAM,eACJ,IAAI,gBACJ,kBAAkB,CAAC,uBAAuB,GAAG;AAAA,IAC3C;AAAA,IACA,cAAc,QAAQ,QAAQ,KAAK,QAAQ,cAAc;AAAA,EAC3D,CAAC;AAEH,MAAI,CAAC,YAAY,CAAC,kBAAkB,CAAC,QAAQ;AAC3C,WAAO,eAAe,EAAE,GAAG,QAAQ,aAAa,IAAI,EAAE,GAAG,OAAO;AAAA,EAClE;AAEA,QAAM,QAAQ,EAAE,UAAU,eAAe;AACzC,QAAM,cAAc,MAAM;AAAA,IACxB,IAAI;AAAA,IACJ;AAAA,IACA,EAAE,QAAQ,UAAU,gBAAgB,WAAW,KAAK;AAAA,IACpD,CAAC;AAAA,IACD;AAAA,EACF;AACA,QAAM,gBAAgB,aAAa,MAAM;AAEzC,MAAI,aAAc,QAAO,EAAE,cAAc,MAAM,YAAY,CAAC,GAAG,cAAc;AAC7E,MAAI,CAAC,cAAe,QAAO,EAAE,GAAG,OAAO;AAEvC,QAAM,cAAc,MAAM;AAAA,IACxB,IAAI;AAAA,IACJ;AAAA,IACA,EAAE,eAAe,UAAU,gBAAgB,QAAQ,UAAU,WAAW,KAAK;AAAA,IAC7E,CAAC;AAAA,IACD;AAAA,EACF;AAEA,QAAM,YAAY,6BAA6B,IAAI,mBAAmB;AACtE,QAAM,aAAa,WAAW,IAAI,OAAO,oBAAI,KAAK,CAAC;AAEnD,QAAM,aAAuB,CAAC;AAC9B,QAAM,oBAAwD,CAAC;AAC/D,aAAW,cAAc,aAAa;AACpC,UAAM,YAAY,WAAW;AAC7B,QAAI,CAAC,UAAW;AAChB,QAAI,eAAe,QAAQ,CAAC,yBAAyB,YAAY,YAAY,SAAS,EAAG;AACzF,UAAM,WAAW,WAAW,WAAW,eAAe;AACtD,UAAM,SAA2B;AAAA,MAC/B,YAAY,WAAW,WAAW,iBAAiB;AAAA,MACnD,UAAU,aAAa,OAAO,OAAO,WAAW;AAAA,IAClD;AACA,UAAM,UAAU,kBAAkB,SAAS;AAC3C,QAAI,SAAS;AACX,cAAQ,KAAK,MAAM;AACnB;AAAA,IACF;AACA,sBAAkB,SAAS,IAAI,CAAC,MAAM;AACtC,eAAW,KAAK,SAAS;AAAA,EAC3B;AAEA,SAAO,EAAE,cAAc,OAAO,YAAY,eAAe,kBAAkB;AAC7E;AASO,SAAS,0BACd,QACA,WACA,MACS;AACT,MAAI,OAAO,aAAc,QAAO;AAChC,MAAI,CAAC,aAAa,CAAC,OAAO,WAAW,SAAS,SAAS,EAAG,QAAO;AACjE,QAAM,UAAU,OAAO,oBAAoB,SAAS;AACpD,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,YAAY,gBAAgB,IAAI;AACtC,MAAI,cAAc,KAAM,QAAO;AAC/B,SAAO,QAAQ;AAAA,IACb,CAAC,YACE,OAAO,eAAe,QAAQ,aAAa,OAAO,gBAClD,OAAO,aAAa,QAAQ,aAAa,OAAO;AAAA,EACrD;AACF;AAEO,SAAS,oBAAoB,QAAuB,WAAoC;AAC7F,MAAI,OAAO,aAAc,QAAO;AAChC,MAAI,CAAC,UAAW,QAAO;AACvB,SAAO,OAAO,WAAW,SAAS,SAAS;AAC7C;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,54 @@
1
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
+ import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
3
+ import { resolveFeatureAccess } from "./featureAccess.js";
4
+ import { MANAGE_PROJECTS_FEATURE, resolveProjectAccess } from "./access.js";
5
+ import { readTimeTrackingSettings } from "./settings.js";
6
+ const logger = createLogger("staff").child({ component: "lib/time-tracking/listProjectAccess" });
7
+ const DENIED_ACCESS = { canManageAll: false, projectIds: [], staffMemberId: null };
8
+ const accessByRequest = /* @__PURE__ */ new WeakMap();
9
+ async function resolveAssignmentGraceDays(container, tenantId) {
10
+ try {
11
+ const configService = container.resolve("moduleConfigService");
12
+ const settings = await readTimeTrackingSettings(configService, { tenantId });
13
+ return settings.access.assignmentGraceDays;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ async function loadProjectAccess(container, userId, tenantId, organizationId) {
19
+ if (!tenantId || !organizationId) return { ...DENIED_ACCESS };
20
+ const scope = { tenantId, organizationId };
21
+ try {
22
+ const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope);
23
+ const em = container.resolve("em");
24
+ return await resolveProjectAccess({
25
+ em: em.fork(),
26
+ userId,
27
+ tenantId,
28
+ organizationId,
29
+ canManageAll: access.allowed,
30
+ userFeatures: access.grantedFeatures,
31
+ assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId)
32
+ });
33
+ } catch (err) {
34
+ logger.error("staff.timesheets.tasks access resolution failed", { err });
35
+ getTelemetryRuntime()?.reportError(err, { module: "staff", code: "staff.task_access_resolution_failed" });
36
+ return { ...DENIED_ACCESS };
37
+ }
38
+ }
39
+ async function resolveListProjectAccess(ctx) {
40
+ const tenantId = ctx.auth?.tenantId ?? null;
41
+ const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
42
+ const load = () => loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId);
43
+ const request = ctx.request;
44
+ if (!request) return load();
45
+ const cached = accessByRequest.get(request);
46
+ if (cached) return cached;
47
+ const pending = load();
48
+ accessByRequest.set(request, pending);
49
+ return pending;
50
+ }
51
+ export {
52
+ resolveListProjectAccess
53
+ };
54
+ //# sourceMappingURL=listProjectAccess.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/staff/lib/time-tracking/listProjectAccess.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { resolveFeatureAccess } from './featureAccess'\nimport { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from './access'\nimport { readTimeTrackingSettings } from './settings'\n\nconst logger = createLogger('staff').child({ component: 'lib/time-tracking/listProjectAccess' })\n\nconst DENIED_ACCESS: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\ntype ContainerLike = { resolve: (name: string) => unknown }\n\ntype ListAccessCtx = Pick<CrudCtx, 'container' | 'auth' | 'selectedOrganizationId' | 'request'>\n\n/** One access decision per request, shared by every filter that needs it. */\nconst accessByRequest = new WeakMap<Request, Promise<ProjectAccess>>()\n\nasync function resolveAssignmentGraceDays(container: ContainerLike, tenantId: string): Promise<number | null> {\n try {\n const configService = container.resolve('moduleConfigService') as ModuleConfigService\n const settings = await readTimeTrackingSettings(configService, { tenantId })\n return settings.access.assignmentGraceDays\n } catch {\n // Fail safe to the documented default rather than widening the window.\n return null\n }\n}\n\nasync function loadProjectAccess(\n container: ContainerLike,\n userId: string | null,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<ProjectAccess> {\n if (!tenantId || !organizationId) return { ...DENIED_ACCESS }\n const scope = { tenantId, organizationId }\n try {\n // One lookup, one authority, and a failure that says so. The previous\n // `catch \u2192 []` could not tell \"denied\" from \"could not ask\", so an RBAC\n // hiccup silently demoted a manager to their own memberships.\n const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope)\n const em = container.resolve('em') as EntityManager\n return await resolveProjectAccess({\n em: em.fork(),\n userId,\n tenantId,\n organizationId,\n canManageAll: access.allowed,\n userFeatures: access.grantedFeatures,\n assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId),\n })\n } catch (err) {\n logger.error('staff.timesheets.tasks access resolution failed', { err })\n getTelemetryRuntime()?.reportError(err, { module: 'staff', code: 'staff.task_access_resolution_failed' })\n return { ...DENIED_ACCESS }\n }\n}\n\n/**\n * The project-access decision of the tasks list, memoised per request and failing\n * closed. Shared by the tasks list route and the task action routes (status move,\n * claim, release) so a task is only ever reachable through one access rule.\n */\nexport async function resolveListProjectAccess(ctx: ListAccessCtx): Promise<ProjectAccess> {\n const tenantId = ctx.auth?.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n const load = () => loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId)\n const request = ctx.request\n if (!request) return load()\n const cached = accessByRequest.get(request)\n if (cached) return cached\n const pending = load()\n accessByRequest.set(request, pending)\n return pending\n}\n"],
5
+ "mappings": "AAEA,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B;AAEpC,SAAS,4BAA4B;AACrC,SAAS,yBAAyB,4BAAgD;AAClF,SAAS,gCAAgC;AAEzC,MAAM,SAAS,aAAa,OAAO,EAAE,MAAM,EAAE,WAAW,sCAAsC,CAAC;AAE/F,MAAM,gBAA+B,EAAE,cAAc,OAAO,YAAY,CAAC,GAAG,eAAe,KAAK;AAOhG,MAAM,kBAAkB,oBAAI,QAAyC;AAErE,eAAe,2BAA2B,WAA0B,UAA0C;AAC5G,MAAI;AACF,UAAM,gBAAgB,UAAU,QAAQ,qBAAqB;AAC7D,UAAM,WAAW,MAAM,yBAAyB,eAAe,EAAE,SAAS,CAAC;AAC3E,WAAO,SAAS,OAAO;AAAA,EACzB,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,kBACb,WACA,QACA,UACA,gBACwB;AACxB,MAAI,CAAC,YAAY,CAAC,eAAgB,QAAO,EAAE,GAAG,cAAc;AAC5D,QAAM,QAAQ,EAAE,UAAU,eAAe;AACzC,MAAI;AAIF,UAAM,SAAS,MAAM,qBAAqB,WAAW,QAAQ,CAAC,uBAAuB,GAAG,KAAK;AAC7F,UAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,WAAO,MAAM,qBAAqB;AAAA,MAChC,IAAI,GAAG,KAAK;AAAA,MACZ;AAAA,MACA;AAAA,MACA;AAAA,MACA,cAAc,OAAO;AAAA,MACrB,cAAc,OAAO;AAAA,MACrB,qBAAqB,MAAM,2BAA2B,WAAW,QAAQ;AAAA,IAC3E,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,MAAM,mDAAmD,EAAE,IAAI,CAAC;AACvE,wBAAoB,GAAG,YAAY,KAAK,EAAE,QAAQ,SAAS,MAAM,sCAAsC,CAAC;AACxG,WAAO,EAAE,GAAG,cAAc;AAAA,EAC5B;AACF;AAOA,eAAsB,yBAAyB,KAA4C;AACzF,QAAM,WAAW,IAAI,MAAM,YAAY;AACvC,QAAM,iBAAiB,IAAI,0BAA0B,IAAI,MAAM,SAAS;AACxE,QAAM,OAAO,MAAM,kBAAkB,IAAI,WAAW,IAAI,MAAM,OAAO,MAAM,UAAU,cAAc;AACnG,QAAM,UAAU,IAAI;AACpB,MAAI,CAAC,QAAS,QAAO,KAAK;AAC1B,QAAM,SAAS,gBAAgB,IAAI,OAAO;AAC1C,MAAI,OAAQ,QAAO;AACnB,QAAM,UAAU,KAAK;AACrB,kBAAgB,IAAI,SAAS,OAAO;AACpC,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -1,10 +1,28 @@
1
+ import { TableNotFoundException } from "@mikro-orm/core";
1
2
  import { createLogger } from "@open-mercato/shared/lib/logger";
3
+ import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
4
+ import { authorizeFeatures } from "@open-mercato/shared/security/featurePolicy";
2
5
  const logger = createLogger("staff").child({ component: "time-tracking/portal-recipients" });
3
6
  const MAX_PORTAL_RECIPIENTS = 100;
7
+ const UNDEFINED_TABLE_SQLSTATE = "42P01";
4
8
  function isNonEmpty(value) {
5
9
  return typeof value === "string" && value.trim().length > 0;
6
10
  }
7
- async function resolvePortalRecipientUserIds(em, scope) {
11
+ function isPortalTableMissingError(error) {
12
+ let current = error;
13
+ for (let depth = 0; depth < 4 && typeof current === "object" && current !== null; depth += 1) {
14
+ if (current instanceof TableNotFoundException) return true;
15
+ const candidate = current;
16
+ if (candidate.code === UNDEFINED_TABLE_SQLSTATE) return true;
17
+ current = candidate.cause;
18
+ }
19
+ return false;
20
+ }
21
+ function reportSwallowedRecipientError(err, code) {
22
+ if (isPortalTableMissingError(err)) return;
23
+ getTelemetryRuntime()?.reportError(err, { module: "staff", code });
24
+ }
25
+ async function resolvePortalRecipientUserIdsStrict(em, scope) {
8
26
  if (!isNonEmpty(scope.tenantId) || !isNonEmpty(scope.organizationId) || !isNonEmpty(scope.customerId)) {
9
27
  return [];
10
28
  }
@@ -12,16 +30,88 @@ async function resolvePortalRecipientUserIds(em, scope) {
12
30
  const db = em.getKysely();
13
31
  const rows = await db.selectFrom("customer_users").select("id").where("tenant_id", "=", scope.tenantId).where("organization_id", "=", scope.organizationId).where("customer_entity_id", "=", scope.customerId).where("is_active", "=", true).where("deleted_at", "is", null).limit(MAX_PORTAL_RECIPIENTS).execute();
14
32
  return rows.map((row) => row.id).filter(isNonEmpty);
33
+ } catch (err) {
34
+ if (isPortalTableMissingError(err)) return [];
35
+ throw err;
36
+ }
37
+ }
38
+ async function resolvePortalRecipientUserIds(em, scope) {
39
+ try {
40
+ return await resolvePortalRecipientUserIdsStrict(em, scope);
15
41
  } catch (err) {
16
42
  logger.warn("Could not resolve portal recipients for a time-tracking customer", {
17
43
  customerId: scope.customerId,
18
44
  err
19
45
  });
46
+ reportSwallowedRecipientError(err, "staff.portal_recipients_resolve_failed");
47
+ return [];
48
+ }
49
+ }
50
+ function readFeatureList(value) {
51
+ let parsed = value;
52
+ if (typeof value === "string") {
53
+ try {
54
+ parsed = JSON.parse(value);
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+ return Array.isArray(parsed) ? parsed.filter(isNonEmpty) : [];
60
+ }
61
+ function mergeGrant(grants, userId, featuresJson, isPortalAdmin) {
62
+ const grant = grants.get(userId) ?? { features: [], isPortalAdmin: false };
63
+ grant.isPortalAdmin = grant.isPortalAdmin || isPortalAdmin === true;
64
+ for (const feature of readFeatureList(featuresJson)) {
65
+ if (!grant.features.includes(feature)) grant.features.push(feature);
66
+ }
67
+ grants.set(userId, grant);
68
+ }
69
+ async function filterPortalRecipientsByFeatureStrict(em, userIds, scope, requiredFeature) {
70
+ const candidates = Array.from(new Set(userIds.filter(isNonEmpty)));
71
+ if (candidates.length === 0 || !isNonEmpty(scope.tenantId) || !isNonEmpty(scope.organizationId)) return [];
72
+ try {
73
+ const db = em.getKysely();
74
+ const userAclRows = await db.selectFrom("customer_user_acls").select(["user_id", "features_json", "is_portal_admin"]).where("tenant_id", "=", scope.tenantId).where("user_id", "in", candidates).execute();
75
+ const userGrants = /* @__PURE__ */ new Map();
76
+ for (const row of userAclRows) mergeGrant(userGrants, row.user_id, row.features_json, row.is_portal_admin);
77
+ const roleCandidates = candidates.filter((userId) => !userGrants.has(userId));
78
+ const roleGrants = /* @__PURE__ */ new Map();
79
+ if (roleCandidates.length > 0) {
80
+ const roleAclRows = await db.selectFrom("customer_user_roles").innerJoin("customer_roles", "customer_roles.id", "customer_user_roles.role_id").innerJoin("customer_role_acls", "customer_role_acls.role_id", "customer_roles.id").select([
81
+ "customer_user_roles.user_id as user_id",
82
+ "customer_role_acls.features_json as features_json",
83
+ "customer_role_acls.is_portal_admin as is_portal_admin"
84
+ ]).where("customer_user_roles.user_id", "in", roleCandidates).where("customer_user_roles.deleted_at", "is", null).where("customer_roles.tenant_id", "=", scope.tenantId).where("customer_roles.organization_id", "=", scope.organizationId).where("customer_roles.deleted_at", "is", null).where("customer_role_acls.tenant_id", "=", scope.tenantId).execute();
85
+ for (const row of roleAclRows) mergeGrant(roleGrants, row.user_id, row.features_json, row.is_portal_admin);
86
+ }
87
+ return candidates.filter((userId) => {
88
+ const grant = userGrants.get(userId) ?? roleGrants.get(userId);
89
+ if (!grant) return false;
90
+ return authorizeFeatures([requiredFeature], { grantedFeatures: grant.features, unrestricted: grant.isPortalAdmin });
91
+ });
92
+ } catch (err) {
93
+ if (isPortalTableMissingError(err)) return [];
94
+ throw err;
95
+ }
96
+ }
97
+ async function filterPortalRecipientsByFeature(em, userIds, scope, requiredFeature) {
98
+ try {
99
+ return await filterPortalRecipientsByFeatureStrict(em, userIds, scope, requiredFeature);
100
+ } catch (err) {
101
+ logger.warn("Could not resolve portal recipient features; treating the audience as empty", {
102
+ feature: requiredFeature,
103
+ err
104
+ });
105
+ reportSwallowedRecipientError(err, "staff.portal_recipients_feature_filter_failed");
20
106
  return [];
21
107
  }
22
108
  }
23
109
  export {
24
110
  MAX_PORTAL_RECIPIENTS,
25
- resolvePortalRecipientUserIds
111
+ filterPortalRecipientsByFeature,
112
+ filterPortalRecipientsByFeatureStrict,
113
+ isPortalTableMissingError,
114
+ resolvePortalRecipientUserIds,
115
+ resolvePortalRecipientUserIdsStrict
26
116
  };
27
117
  //# sourceMappingURL=portalRecipients.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/staff/lib/time-tracking/portalRecipients.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('staff').child({ component: 'time-tracking/portal-recipients' })\n\n/**\n * EP-50. The customer-user side of the portal contract, expressed as table and\n * column **names** rather than imports.\n *\n * Staff is slated for extraction into `@open-mercato/staff`, so it may not take a\n * static dependency on `customer_accounts`; `requires` in `index.ts` stays\n * `['planner', 'resources']`. A Kysely query against the string\n * `'customer_users'` reads the mapping without an import edge, exactly the way\n * `warranty_claims/commands/claims.ts` does \u2014 and if the table is absent because\n * the module is not installed, the query throws and this returns an empty list,\n * which every caller treats as \"no portal audience\".\n *\n * `customer_users.customer_entity_id` is the FK into `customers:customer_entity`,\n * which is the same id `staff_time_reports.customer_id` holds (EP-44 declares that\n * link). That equality is the whole ownership model: a portal user reaches a\n * report when, and only when, their own `customer_entity_id` matches the report's\n * `customer_id` inside the same tenant and organization.\n */\n\ntype CustomerUsersDb = {\n customer_users: {\n id: string\n tenant_id: string | null\n organization_id: string | null\n customer_entity_id: string | null\n is_active: boolean\n deleted_at: Date | null\n }\n}\n\nexport type PortalRecipientScope = {\n tenantId: string\n organizationId: string\n customerId: string\n}\n\n/** A broadcast fan-out, not a mailing list \u2014 a customer with more portal users than this gets a truncated audience rather than an unbounded query. */\nexport const MAX_PORTAL_RECIPIENTS = 100\n\nfunction isNonEmpty(value: unknown): value is string {\n return typeof value === 'string' && value.trim().length > 0\n}\n\nexport async function resolvePortalRecipientUserIds(\n em: EntityManager,\n scope: PortalRecipientScope,\n): Promise<string[]> {\n if (!isNonEmpty(scope.tenantId) || !isNonEmpty(scope.organizationId) || !isNonEmpty(scope.customerId)) {\n return []\n }\n try {\n const db = em.getKysely<CustomerUsersDb>()\n const rows = await db\n .selectFrom('customer_users')\n .select('id')\n .where('tenant_id', '=', scope.tenantId)\n .where('organization_id', '=', scope.organizationId)\n .where('customer_entity_id', '=', scope.customerId)\n .where('is_active', '=', true)\n .where('deleted_at', 'is', null)\n .limit(MAX_PORTAL_RECIPIENTS)\n .execute()\n return rows.map((row) => row.id).filter(isNonEmpty)\n } catch (err) {\n logger.warn('Could not resolve portal recipients for a time-tracking customer', {\n customerId: scope.customerId,\n err,\n })\n return []\n }\n}\n"],
5
- "mappings": "AACA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,OAAO,EAAE,MAAM,EAAE,WAAW,kCAAkC,CAAC;AAuCpF,MAAM,wBAAwB;AAErC,SAAS,WAAW,OAAiC;AACnD,SAAO,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS;AAC5D;AAEA,eAAsB,8BACpB,IACA,OACmB;AACnB,MAAI,CAAC,WAAW,MAAM,QAAQ,KAAK,CAAC,WAAW,MAAM,cAAc,KAAK,CAAC,WAAW,MAAM,UAAU,GAAG;AACrG,WAAO,CAAC;AAAA,EACV;AACA,MAAI;AACF,UAAM,KAAK,GAAG,UAA2B;AACzC,UAAM,OAAO,MAAM,GAChB,WAAW,gBAAgB,EAC3B,OAAO,IAAI,EACX,MAAM,aAAa,KAAK,MAAM,QAAQ,EACtC,MAAM,mBAAmB,KAAK,MAAM,cAAc,EAClD,MAAM,sBAAsB,KAAK,MAAM,UAAU,EACjD,MAAM,aAAa,KAAK,IAAI,EAC5B,MAAM,cAAc,MAAM,IAAI,EAC9B,MAAM,qBAAqB,EAC3B,QAAQ;AACX,WAAO,KAAK,IAAI,CAAC,QAAQ,IAAI,EAAE,EAAE,OAAO,UAAU;AAAA,EACpD,SAAS,KAAK;AACZ,WAAO,KAAK,oEAAoE;AAAA,MAC9E,YAAY,MAAM;AAAA,MAClB;AAAA,IACF,CAAC;AACD,WAAO,CAAC;AAAA,EACV;AACF;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { TableNotFoundException } from '@mikro-orm/core'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\nconst logger = createLogger('staff').child({ component: 'time-tracking/portal-recipients' })\n\n/**\n * EP-50. The customer-user side of the portal contract, expressed as table and\n * column **names** rather than imports.\n *\n * Staff is slated for extraction into `@open-mercato/staff`, so it may not take a\n * static dependency on `customer_accounts`; `requires` in `index.ts` never gains it.\n * A Kysely query against the string\n * `'customer_users'` reads the mapping without an import edge, exactly the way\n * `warranty_claims/commands/claims.ts` does \u2014 and if the table is absent because\n * the module is not installed, the query throws \"relation does not exist\" and the\n * audience is empty, which every caller treats as \"no portal audience\".\n *\n * Each helper comes in two flavours. The `...Strict` variant returns `[]` only for\n * an expected empty audience (no rows, or the `customer_accounts` tables absent)\n * and rethrows any other query failure, so a caller that records \"the portal was\n * told\" can retry instead of recording a delivery that never happened. The lenient\n * variant keeps the historical swallow-and-return-`[]` contract and reports the\n * unexpected failure to telemetry. Both fail closed: neither ever returns an\n * unfiltered audience.\n *\n * `customer_users.customer_entity_id` is the FK into `customers:customer_entity`,\n * which is the same id `staff_time_reports.customer_id` holds (EP-44 declares that\n * link). That equality is the whole ownership model: a portal user reaches a\n * report when, and only when, their own `customer_entity_id` matches the report's\n * `customer_id` inside the same tenant and organization.\n */\n\ntype CustomerUsersDb = {\n customer_users: {\n id: string\n tenant_id: string | null\n organization_id: string | null\n customer_entity_id: string | null\n is_active: boolean\n deleted_at: Date | null\n }\n}\n\nexport type PortalRecipientScope = {\n tenantId: string\n organizationId: string\n customerId: string\n}\n\n/** A broadcast fan-out, not a mailing list \u2014 a customer with more portal users than this gets a truncated audience rather than an unbounded query. */\nexport const MAX_PORTAL_RECIPIENTS = 100\n\nconst UNDEFINED_TABLE_SQLSTATE = '42P01'\n\nfunction isNonEmpty(value: unknown): value is string {\n return typeof value === 'string' && value.trim().length > 0\n}\n\n/** The `customer_accounts` tables are not installed: an expected \"no portal audience\", not a failure. */\nexport function isPortalTableMissingError(error: unknown): boolean {\n let current: unknown = error\n for (let depth = 0; depth < 4 && typeof current === 'object' && current !== null; depth += 1) {\n if (current instanceof TableNotFoundException) return true\n const candidate = current as { code?: unknown; cause?: unknown }\n if (candidate.code === UNDEFINED_TABLE_SQLSTATE) return true\n current = candidate.cause\n }\n return false\n}\n\nfunction reportSwallowedRecipientError(err: unknown, code: string): void {\n if (isPortalTableMissingError(err)) return\n getTelemetryRuntime()?.reportError(err, { module: 'staff', code })\n}\n\nexport async function resolvePortalRecipientUserIdsStrict(\n em: EntityManager,\n scope: PortalRecipientScope,\n): Promise<string[]> {\n if (!isNonEmpty(scope.tenantId) || !isNonEmpty(scope.organizationId) || !isNonEmpty(scope.customerId)) {\n return []\n }\n try {\n const db = em.getKysely<CustomerUsersDb>()\n const rows = await db\n .selectFrom('customer_users')\n .select('id')\n .where('tenant_id', '=', scope.tenantId)\n .where('organization_id', '=', scope.organizationId)\n .where('customer_entity_id', '=', scope.customerId)\n .where('is_active', '=', true)\n .where('deleted_at', 'is', null)\n .limit(MAX_PORTAL_RECIPIENTS)\n .execute()\n return rows.map((row) => row.id).filter(isNonEmpty)\n } catch (err) {\n if (isPortalTableMissingError(err)) return []\n throw err\n }\n}\n\nexport async function resolvePortalRecipientUserIds(\n em: EntityManager,\n scope: PortalRecipientScope,\n): Promise<string[]> {\n try {\n return await resolvePortalRecipientUserIdsStrict(em, scope)\n } catch (err) {\n logger.warn('Could not resolve portal recipients for a time-tracking customer', {\n customerId: scope.customerId,\n err,\n })\n reportSwallowedRecipientError(err, 'staff.portal_recipients_resolve_failed')\n return []\n }\n}\n\ntype CustomerAclDb = {\n customer_user_acls: {\n user_id: string\n tenant_id: string\n features_json: unknown\n is_portal_admin: boolean\n }\n customer_user_roles: {\n user_id: string\n role_id: string\n deleted_at: Date | null\n }\n customer_roles: {\n id: string\n tenant_id: string\n organization_id: string\n deleted_at: Date | null\n }\n customer_role_acls: {\n role_id: string\n tenant_id: string\n features_json: unknown\n is_portal_admin: boolean\n }\n}\n\ntype PortalAclGrant = { features: string[]; isPortalAdmin: boolean }\n\nfunction readFeatureList(value: unknown): string[] {\n let parsed: unknown = value\n if (typeof value === 'string') {\n try {\n parsed = JSON.parse(value)\n } catch {\n return []\n }\n }\n return Array.isArray(parsed) ? parsed.filter(isNonEmpty) : []\n}\n\nfunction mergeGrant(grants: Map<string, PortalAclGrant>, userId: string, featuresJson: unknown, isPortalAdmin: boolean): void {\n const grant = grants.get(userId) ?? { features: [], isPortalAdmin: false }\n grant.isPortalAdmin = grant.isPortalAdmin || isPortalAdmin === true\n for (const feature of readFeatureList(featuresJson)) {\n if (!grant.features.includes(feature)) grant.features.push(feature)\n }\n grants.set(userId, grant)\n}\n\n/**\n * Narrows a portal audience to the customer users holding `requiredFeature`, resolved the\n * way `customer_accounts`' `CustomerRbacService.loadAcl` does: a per-user ACL row replaces\n * the user's role ACLs, a portal admin is unrestricted, and grants match wildcard-aware via\n * `authorizeFeatures`. Read by table name for the same decoupling reason as above. The\n * strict variant returns `[]` when the module is absent and rethrows any other failure;\n * neither variant ever returns an unfiltered audience.\n */\nexport async function filterPortalRecipientsByFeatureStrict(\n em: EntityManager,\n userIds: string[],\n scope: { tenantId: string; organizationId: string },\n requiredFeature: string,\n): Promise<string[]> {\n const candidates = Array.from(new Set(userIds.filter(isNonEmpty)))\n if (candidates.length === 0 || !isNonEmpty(scope.tenantId) || !isNonEmpty(scope.organizationId)) return []\n try {\n const db = em.getKysely<CustomerAclDb>()\n const userAclRows = await db\n .selectFrom('customer_user_acls')\n .select(['user_id', 'features_json', 'is_portal_admin'])\n .where('tenant_id', '=', scope.tenantId)\n .where('user_id', 'in', candidates)\n .execute()\n const userGrants = new Map<string, PortalAclGrant>()\n for (const row of userAclRows) mergeGrant(userGrants, row.user_id, row.features_json, row.is_portal_admin)\n const roleCandidates = candidates.filter((userId) => !userGrants.has(userId))\n const roleGrants = new Map<string, PortalAclGrant>()\n if (roleCandidates.length > 0) {\n const roleAclRows = await db\n .selectFrom('customer_user_roles')\n .innerJoin('customer_roles', 'customer_roles.id', 'customer_user_roles.role_id')\n .innerJoin('customer_role_acls', 'customer_role_acls.role_id', 'customer_roles.id')\n .select([\n 'customer_user_roles.user_id as user_id',\n 'customer_role_acls.features_json as features_json',\n 'customer_role_acls.is_portal_admin as is_portal_admin',\n ])\n .where('customer_user_roles.user_id', 'in', roleCandidates)\n .where('customer_user_roles.deleted_at', 'is', null)\n .where('customer_roles.tenant_id', '=', scope.tenantId)\n .where('customer_roles.organization_id', '=', scope.organizationId)\n .where('customer_roles.deleted_at', 'is', null)\n .where('customer_role_acls.tenant_id', '=', scope.tenantId)\n .execute()\n for (const row of roleAclRows) mergeGrant(roleGrants, row.user_id, row.features_json, row.is_portal_admin)\n }\n return candidates.filter((userId) => {\n const grant = userGrants.get(userId) ?? roleGrants.get(userId)\n if (!grant) return false\n return authorizeFeatures([requiredFeature], { grantedFeatures: grant.features, unrestricted: grant.isPortalAdmin })\n })\n } catch (err) {\n if (isPortalTableMissingError(err)) return []\n throw err\n }\n}\n\nexport async function filterPortalRecipientsByFeature(\n em: EntityManager,\n userIds: string[],\n scope: { tenantId: string; organizationId: string },\n requiredFeature: string,\n): Promise<string[]> {\n try {\n return await filterPortalRecipientsByFeatureStrict(em, userIds, scope, requiredFeature)\n } catch (err) {\n logger.warn('Could not resolve portal recipient features; treating the audience as empty', {\n feature: requiredFeature,\n err,\n })\n reportSwallowedRecipientError(err, 'staff.portal_recipients_feature_filter_failed')\n return []\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,112 @@
1
+ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
2
+ import { StaffTeamMember, StaffTimeProjectMember } from "../../data/entities.js";
3
+ import { DEFAULT_ASSIGNMENT_GRACE_DAYS, MAX_ASSIGNMENT_GRACE_DAYS } from "./settingKeys.js";
4
+ const MS_PER_DAY = 864e5;
5
+ const ISO_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/;
6
+ function normalizeAssignmentGraceDays(value) {
7
+ if (typeof value !== "number" || !Number.isInteger(value)) return DEFAULT_ASSIGNMENT_GRACE_DAYS;
8
+ if (value < 0 || value > MAX_ASSIGNMENT_GRACE_DAYS) return DEFAULT_ASSIGNMENT_GRACE_DAYS;
9
+ return value;
10
+ }
11
+ function toDayIndex(value) {
12
+ if (value == null) return null;
13
+ if (value instanceof Date) {
14
+ if (Number.isNaN(value.getTime())) return null;
15
+ return Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / MS_PER_DAY;
16
+ }
17
+ if (typeof value === "string") {
18
+ const match = ISO_DATE_PREFIX.exec(value.trim());
19
+ if (!match) return null;
20
+ return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) / MS_PER_DAY;
21
+ }
22
+ return null;
23
+ }
24
+ function toEntryDayIndex(value) {
25
+ if (value instanceof Date) {
26
+ if (Number.isNaN(value.getTime())) return null;
27
+ return Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()) / MS_PER_DAY;
28
+ }
29
+ return toDayIndex(value);
30
+ }
31
+ function isWithinAssignmentWindow(membership, todayIndex, graceDays) {
32
+ if (membership.assignedStartDate != null) {
33
+ const startIndex = toDayIndex(membership.assignedStartDate);
34
+ if (startIndex === null || todayIndex < startIndex) return false;
35
+ }
36
+ if (membership.assignedEndDate == null) return true;
37
+ const endIndex = toDayIndex(membership.assignedEndDate);
38
+ if (endIndex === null) return false;
39
+ return todayIndex <= endIndex + graceDays;
40
+ }
41
+ function isActiveAssignment(membership, now, graceDays) {
42
+ if (membership.status !== "active" || membership.deletedAt) return false;
43
+ const todayIndex = toDayIndex(now);
44
+ if (todayIndex === null) return false;
45
+ return isWithinAssignmentWindow(membership, todayIndex, normalizeAssignmentGraceDays(graceDays));
46
+ }
47
+ async function loadActiveProjectMembers(em, scope, timeProjectId, options = {}) {
48
+ const where = {
49
+ timeProjectId,
50
+ tenantId: scope.tenantId,
51
+ organizationId: scope.organizationId,
52
+ status: "active",
53
+ deletedAt: null
54
+ };
55
+ if (options.assignersOnly) where.isAssigner = true;
56
+ const memberships = await em.find(StaffTimeProjectMember, where);
57
+ const now = options.now ?? /* @__PURE__ */ new Date();
58
+ const graceDays = normalizeAssignmentGraceDays(options.graceDays);
59
+ const active = memberships.filter((membership) => isActiveAssignment(membership, now, graceDays));
60
+ if (active.length === 0) return [];
61
+ const staffMembers = await findWithDecryption(
62
+ em,
63
+ StaffTeamMember,
64
+ {
65
+ id: { $in: Array.from(new Set(active.map((membership) => membership.staffMemberId))) },
66
+ tenantId: scope.tenantId,
67
+ organizationId: scope.organizationId,
68
+ deletedAt: null
69
+ },
70
+ {},
71
+ scope
72
+ );
73
+ const userByMember = new Map(staffMembers.map((member) => [member.id, member.userId ?? null]));
74
+ return active.filter((membership) => userByMember.has(membership.staffMemberId)).map((membership) => ({
75
+ membershipId: membership.id,
76
+ staffMemberId: membership.staffMemberId,
77
+ userId: userByMember.get(membership.staffMemberId) ?? null,
78
+ isAssigner: membership.isAssigner === true
79
+ }));
80
+ }
81
+ async function resolveUsableDefaultAssignee(em, scope, project, options = {}) {
82
+ const candidate = project.defaultAssigneeStaffMemberId ?? null;
83
+ if (!candidate) return null;
84
+ const members = await loadActiveProjectMembers(em, scope, project.id, options);
85
+ return members.some((member) => member.staffMemberId === candidate) ? candidate : null;
86
+ }
87
+ async function isActiveProjectAssigner(em, scope, timeProjectId, staffMemberId, options = {}) {
88
+ const assigners = await loadActiveProjectMembers(em, scope, timeProjectId, { ...options, assignersOnly: true });
89
+ return assigners.some((member) => member.staffMemberId === staffMemberId);
90
+ }
91
+ async function resolveAssignmentGraceDaysFromContainer(container, tenantId) {
92
+ try {
93
+ const { readTimeTrackingSettings } = await import("./settings.js");
94
+ const configService = container.resolve("moduleConfigService");
95
+ const settings = await readTimeTrackingSettings(configService, { tenantId });
96
+ return settings.access.assignmentGraceDays;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+ export {
102
+ isActiveAssignment,
103
+ isActiveProjectAssigner,
104
+ isWithinAssignmentWindow,
105
+ loadActiveProjectMembers,
106
+ normalizeAssignmentGraceDays,
107
+ resolveAssignmentGraceDaysFromContainer,
108
+ resolveUsableDefaultAssignee,
109
+ toDayIndex,
110
+ toEntryDayIndex
111
+ };
112
+ //# sourceMappingURL=projectAssignment.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/staff/lib/time-tracking/projectAssignment.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { StaffTeamMember, StaffTimeProjectMember } from '../../data/entities'\nimport { DEFAULT_ASSIGNMENT_GRACE_DAYS, MAX_ASSIGNMENT_GRACE_DAYS } from './settingKeys'\n\nconst MS_PER_DAY = 86_400_000\nconst ISO_DATE_PREFIX = /^(\\d{4})-(\\d{2})-(\\d{2})/\n\nexport function normalizeAssignmentGraceDays(value: unknown): number {\n if (typeof value !== 'number' || !Number.isInteger(value)) return DEFAULT_ASSIGNMENT_GRACE_DAYS\n if (value < 0 || value > MAX_ASSIGNMENT_GRACE_DAYS) return DEFAULT_ASSIGNMENT_GRACE_DAYS\n return value\n}\n\n/**\n * `assigned_start_date` / `assigned_end_date` are `date` columns, so both bounds\n * are compared as whole days rather than timestamps. Date instances are read\n * through their local calendar fields because the postgres driver materialises a\n * `date` as local midnight, and \"today\" likewise means the server's local day.\n */\nexport function toDayIndex(value: unknown): number | null {\n if (value == null) return null\n if (value instanceof Date) {\n if (Number.isNaN(value.getTime())) return null\n return Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / MS_PER_DAY\n }\n if (typeof value === 'string') {\n const match = ISO_DATE_PREFIX.exec(value.trim())\n if (!match) return null\n return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) / MS_PER_DAY\n }\n return null\n}\n\n/**\n * An entry date is a calendar day the client sent as `YYYY-MM-DD`, which\n * `z.coerce.date()` turns into UTC midnight \u2014 so a Date is read through its UTC\n * fields here, the same way the grid addresses a cell (D-8).\n */\nexport function toEntryDayIndex(value: unknown): number | null {\n if (value instanceof Date) {\n if (Number.isNaN(value.getTime())) return null\n return Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()) / MS_PER_DAY\n }\n return toDayIndex(value)\n}\n\n/**\n * A membership only grants access inside its assignment window: from\n * `assigned_start_date` until `assigned_end_date` plus the tenant's grace days.\n * A null end date is open-ended and never expires.\n */\nexport function isWithinAssignmentWindow(\n membership: Pick<StaffTimeProjectMember, 'assignedStartDate' | 'assignedEndDate'>,\n todayIndex: number,\n graceDays: number,\n): boolean {\n if (membership.assignedStartDate != null) {\n const startIndex = toDayIndex(membership.assignedStartDate)\n if (startIndex === null || todayIndex < startIndex) return false\n }\n if (membership.assignedEndDate == null) return true\n const endIndex = toDayIndex(membership.assignedEndDate)\n if (endIndex === null) return false\n return todayIndex <= endIndex + graceDays\n}\n\n\nexport type ProjectAssignmentScope = {\n tenantId: string\n organizationId: string\n}\n\nexport type ActiveProjectMember = {\n membershipId: string\n staffMemberId: string\n userId: string | null\n isAssigner: boolean\n}\n\ntype MembershipLike = Pick<\n StaffTimeProjectMember,\n 'status' | 'deletedAt' | 'assignedStartDate' | 'assignedEndDate'\n>\n\nexport function isActiveAssignment(membership: MembershipLike, now: Date, graceDays: number): boolean {\n if (membership.status !== 'active' || membership.deletedAt) return false\n const todayIndex = toDayIndex(now)\n if (todayIndex === null) return false\n return isWithinAssignmentWindow(membership, todayIndex, normalizeAssignmentGraceDays(graceDays))\n}\n\nexport async function loadActiveProjectMembers(\n em: EntityManager,\n scope: ProjectAssignmentScope,\n timeProjectId: string,\n options: { graceDays?: number | null; now?: Date; assignersOnly?: boolean } = {},\n): Promise<ActiveProjectMember[]> {\n const where: Record<string, unknown> = {\n timeProjectId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n status: 'active',\n deletedAt: null,\n }\n if (options.assignersOnly) where.isAssigner = true\n const memberships = await em.find(StaffTimeProjectMember, where)\n const now = options.now ?? new Date()\n const graceDays = normalizeAssignmentGraceDays(options.graceDays)\n const active = memberships.filter((membership) => isActiveAssignment(membership, now, graceDays))\n if (active.length === 0) return []\n const staffMembers = await findWithDecryption(\n em,\n StaffTeamMember,\n {\n id: { $in: Array.from(new Set(active.map((membership) => membership.staffMemberId))) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const userByMember = new Map(staffMembers.map((member) => [member.id, member.userId ?? null]))\n return active\n .filter((membership) => userByMember.has(membership.staffMemberId))\n .map((membership) => ({\n membershipId: membership.id,\n staffMemberId: membership.staffMemberId,\n userId: userByMember.get(membership.staffMemberId) ?? null,\n isAssigner: membership.isAssigner === true,\n }))\n}\n\nexport async function resolveUsableDefaultAssignee(\n em: EntityManager,\n scope: ProjectAssignmentScope,\n project: { id: string; defaultAssigneeStaffMemberId?: string | null },\n options: { graceDays?: number | null; now?: Date } = {},\n): Promise<string | null> {\n const candidate = project.defaultAssigneeStaffMemberId ?? null\n if (!candidate) return null\n const members = await loadActiveProjectMembers(em, scope, project.id, options)\n return members.some((member) => member.staffMemberId === candidate) ? candidate : null\n}\n\nexport async function isActiveProjectAssigner(\n em: EntityManager,\n scope: ProjectAssignmentScope,\n timeProjectId: string,\n staffMemberId: string,\n options: { graceDays?: number | null; now?: Date } = {},\n): Promise<boolean> {\n const assigners = await loadActiveProjectMembers(em, scope, timeProjectId, { ...options, assignersOnly: true })\n return assigners.some((member) => member.staffMemberId === staffMemberId)\n}\n\ntype ConfigContainer = { resolve: (name: string) => unknown }\n\nexport async function resolveAssignmentGraceDaysFromContainer(\n container: ConfigContainer,\n tenantId: string,\n): Promise<number | null> {\n try {\n const { readTimeTrackingSettings } = await import('./settings')\n const configService = container.resolve('moduleConfigService') as Parameters<typeof readTimeTrackingSettings>[0]\n const settings = await readTimeTrackingSettings(configService, { tenantId })\n return settings.access.assignmentGraceDays\n } catch {\n return null\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -8,6 +8,9 @@ const TIME_TRACKING_DEFAULTS_GROUP = "defaults";
8
8
  const TIME_TRACKING_TARGETS_GROUP = "targets";
9
9
  const TIME_TRACKING_WARNINGS_GROUP = "warnings";
10
10
  const TIME_TRACKING_ACCESS_GROUP = "access";
11
+ const TIME_TRACKING_SUPPORT_GROUP = "support";
12
+ const DEFAULT_SUPPORT_REFERENCE_PREFIX = "SUP";
13
+ const supportReferencePrefixSchema = z.string().regex(/^[A-Z][A-Z0-9]{1,9}$/);
11
14
  const DEFAULT_ASSIGNMENT_GRACE_DAYS = 14;
12
15
  const MAX_ASSIGNMENT_GRACE_DAYS = 365;
13
16
  const timeTrackingRoundingUnitMinutesSchema = z.union([
@@ -132,6 +135,14 @@ const BUILT_IN_SETTING_KEYS = [
132
135
  default: DEFAULT_ASSIGNMENT_GRACE_DAYS,
133
136
  labelKey: "staff.time_tracking.settings.access.graceDays",
134
137
  priority: BUILT_IN_STRATEGY_PRIORITY
138
+ },
139
+ {
140
+ group: TIME_TRACKING_SUPPORT_GROUP,
141
+ key: "referencePrefix",
142
+ schema: supportReferencePrefixSchema,
143
+ default: DEFAULT_SUPPORT_REFERENCE_PREFIX,
144
+ labelKey: "staff.support.settings.referencePrefix",
145
+ priority: BUILT_IN_STRATEGY_PRIORITY
135
146
  }
136
147
  ];
137
148
  for (const input of BUILT_IN_SETTING_KEYS) {
@@ -190,11 +201,13 @@ function normalizeTimeTrackingSettingsRecord(input) {
190
201
  export {
191
202
  BUILT_IN_TIME_TRACKING_SETTING_KEY_IDS,
192
203
  DEFAULT_ASSIGNMENT_GRACE_DAYS,
204
+ DEFAULT_SUPPORT_REFERENCE_PREFIX,
193
205
  MAX_ASSIGNMENT_GRACE_DAYS,
194
206
  TIME_TRACKING_ACCESS_GROUP,
195
207
  TIME_TRACKING_DEFAULTS_GROUP,
196
208
  TIME_TRACKING_ROUNDING_GROUP,
197
209
  TIME_TRACKING_SETTING_KEY_REGISTRY_ID,
210
+ TIME_TRACKING_SUPPORT_GROUP,
198
211
  TIME_TRACKING_TARGETS_GROUP,
199
212
  TIME_TRACKING_WARNINGS_GROUP,
200
213
  buildDefaultTimeTrackingSettings,
@@ -205,6 +218,7 @@ export {
205
218
  listTimeTrackingSettingKeys,
206
219
  normalizeTimeTrackingSettingsRecord,
207
220
  registerTimeTrackingSettingKey,
221
+ supportReferencePrefixSchema,
208
222
  timeTrackingRoundingDirectionSchema,
209
223
  timeTrackingRoundingUnitMinutesSchema,
210
224
  timeTrackingSettingGroups,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/staff/lib/time-tracking/settingKeys.ts"],
4
- "sourcesContent": ["/**\n * EP-42 \u2014 the time-tracking settings key registry.\n *\n * `TIME_TRACKING_SETTING_KEYS` used to be a frozen array, `normalizeTimeTrackingSettings`\n * a hand-written coercion per key, and `staffTimeTrackingSettingsSchema` a literal\n * `z.object`. All three now read this registry, and the eight keys the module shipped\n * are registered here as built-ins \u2014 so with no contribution the read, the write, the\n * validating schema and the defaults are byte-identical to what they were.\n *\n * Storage is `ModuleConfigService` under module id `staff.time_tracking`, one config\n * row per key, named `<group>.<key>` and scoped by **tenant only** (spec \u00A710: time\n * tracking settings are tenant-global; there is no per-organization override and no\n * per-project override). A contributed key inherits exactly that scope \u2014 it is written\n * with `{ tenantId }` and `organization_id` stays null.\n */\n\nimport { z } from 'zod'\nimport { extensionPoints } from '@open-mercato/core/modules/staff/extension-points'\nimport { BUILT_IN_STRATEGY_PRIORITY, createStrategyRegistry } from './registries/registry'\nimport { DEFAULT_ROUNDING_SETTINGS } from './rounding'\n\nexport const TIME_TRACKING_SETTING_KEY_REGISTRY_ID = extensionPoints.hosts.settingKeyRegistry.spotId\n\nexport const TIME_TRACKING_ROUNDING_GROUP = 'rounding'\nexport const TIME_TRACKING_DEFAULTS_GROUP = 'defaults'\nexport const TIME_TRACKING_TARGETS_GROUP = 'targets'\nexport const TIME_TRACKING_WARNINGS_GROUP = 'warnings'\nexport const TIME_TRACKING_ACCESS_GROUP = 'access'\n\n/**\n * Days past `assigned_end_date` that project access survives (spec D-12). The\n * grace window exists so a consultant rolled off at the end of a month can still\n * log and correct that month's time; `0` ends access the day the assignment ends.\n */\nexport const DEFAULT_ASSIGNMENT_GRACE_DAYS = 14\n\nexport const MAX_ASSIGNMENT_GRACE_DAYS = 365\n\nexport const timeTrackingRoundingUnitMinutesSchema = z.union([\n z.literal(0),\n z.literal(5),\n z.literal(10),\n z.literal(15),\n])\n\nexport const timeTrackingRoundingDirectionSchema = z.enum(['up', 'nearest'])\n\nexport type TimeTrackingSettingKeyInput = {\n /** Group the value lives under in the settings object, e.g. `rounding`. */\n group: string\n /** Leaf name inside the group, e.g. `unitMinutes`. */\n key: string\n /** Validates the stored value; an invalid one falls back to `default`. */\n schema: z.ZodTypeAny\n /** Used when the tenant never stored the key and when the stored value no longer validates. */\n default: unknown\n /** i18n key for the label a settings section renders. Never a literal \u2014 the module holds no user-facing strings. */\n labelKey: string\n /** Higher first in `listTimeTrackingSettingKeys()`; built-ins sit below every contribution. */\n priority?: number\n}\n\nexport type TimeTrackingSettingKeyDefinition = TimeTrackingSettingKeyInput & {\n /** `<group>.<key>` \u2014 the `ModuleConfigService` config name. */\n id: string\n builtIn: boolean\n}\n\nconst registry = createStrategyRegistry<TimeTrackingSettingKeyDefinition>(\n TIME_TRACKING_SETTING_KEY_REGISTRY_ID,\n)\n\nconst builtInIds = new Set<string>()\n\nexport function timeTrackingSettingStorageName(group: string, key: string): string {\n return `${group}.${key}`\n}\n\nfunction registerKey(input: TimeTrackingSettingKeyInput, builtIn: boolean): () => void {\n const group = input.group.trim()\n const key = input.key.trim()\n if (!group || !key) {\n throw new Error('[internal] a time-tracking setting key requires a non-empty group and key')\n }\n const id = timeTrackingSettingStorageName(group, key)\n if (!builtIn && builtInIds.has(id)) {\n throw new Error(`[internal] ${id} is a built-in time-tracking setting key and cannot be replaced`)\n }\n const parsed = input.schema.safeParse(input.default)\n if (!parsed.success) {\n throw new Error(`[internal] the default for the time-tracking setting key ${id} fails its own schema`)\n }\n const definition = { ...input, group, key, id, builtIn }\n if (builtIn) {\n registry.registerBuiltIn(definition)\n return () => {}\n }\n return registry.register(definition)\n}\n\n/**\n * Contribute a tenant-scoped time-tracking setting key. Pair it with a widget on the\n * `staff.time_tracking.settings:sections` injection spot (EP-26) to render it: the spot\n * context carries `values` for every contributed key and a `setValue(id, value)` that\n * writes into the page draft, so the page's own Save round-trips the contribution.\n */\nexport function registerTimeTrackingSettingKey(input: TimeTrackingSettingKeyInput): () => void {\n return registerKey(input, false)\n}\n\nexport function listTimeTrackingSettingKeys(): TimeTrackingSettingKeyDefinition[] {\n return registry.list()\n}\n\nexport function getTimeTrackingSettingKey(id: string | null | undefined): TimeTrackingSettingKeyDefinition | null {\n return registry.get(id)\n}\n\nexport function timeTrackingSettingKeyIds(): string[] {\n return registry.ids()\n}\n\n/** The contributed keys only \u2014 the eight built-ins are rendered by the settings page's own form. */\nexport function contributedTimeTrackingSettingKeys(): TimeTrackingSettingKeyDefinition[] {\n return registry.list().filter((entry) => !entry.builtIn)\n}\n\nexport function timeTrackingSettingGroups(): string[] {\n const groups: string[] = []\n for (const entry of registry.list()) {\n if (!groups.includes(entry.group)) groups.push(entry.group)\n }\n return groups\n}\n\nconst BUILT_IN_SETTING_KEYS: readonly TimeTrackingSettingKeyInput[] = [\n {\n group: TIME_TRACKING_ROUNDING_GROUP,\n key: 'unitMinutes',\n schema: timeTrackingRoundingUnitMinutesSchema,\n default: DEFAULT_ROUNDING_SETTINGS.unitMinutes,\n labelKey: 'staff.time_tracking.settings.rounding.unit',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_ROUNDING_GROUP,\n key: 'direction',\n schema: timeTrackingRoundingDirectionSchema,\n default: DEFAULT_ROUNDING_SETTINGS.direction,\n labelKey: 'staff.time_tracking.settings.rounding.direction',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_DEFAULTS_GROUP,\n key: 'billable',\n schema: z.boolean(),\n default: true,\n labelKey: 'staff.time_tracking.settings.defaults.billable',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_DEFAULTS_GROUP,\n key: 'chainStartFromPreviousEnd',\n schema: z.boolean(),\n default: true,\n labelKey: 'staff.time_tracking.settings.defaults.chainStart',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_TARGETS_GROUP,\n key: 'dailyHours',\n schema: z.number().min(0).max(24).nullable(),\n default: 8,\n labelKey: 'staff.time_tracking.settings.targets.dailyHours',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_WARNINGS_GROUP,\n key: 'overlap',\n schema: z.boolean(),\n default: true,\n labelKey: 'staff.time_tracking.settings.warnings.overlap',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_WARNINGS_GROUP,\n key: 'runningTimer',\n schema: z.boolean(),\n default: true,\n labelKey: 'staff.time_tracking.settings.warnings.runningTimer',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_ACCESS_GROUP,\n key: 'assignmentGraceDays',\n schema: z.number().int().min(0).max(MAX_ASSIGNMENT_GRACE_DAYS),\n default: DEFAULT_ASSIGNMENT_GRACE_DAYS,\n labelKey: 'staff.time_tracking.settings.access.graceDays',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n]\n\nfor (const input of BUILT_IN_SETTING_KEYS) {\n registerKey(input, true)\n builtInIds.add(timeTrackingSettingStorageName(input.group, input.key))\n}\n\nexport const BUILT_IN_TIME_TRACKING_SETTING_KEY_IDS: readonly string[] = Object.freeze(\n BUILT_IN_SETTING_KEYS.map((input) => timeTrackingSettingStorageName(input.group, input.key)),\n)\n\nexport function isBuiltInTimeTrackingSettingKey(id: string): boolean {\n return builtInIds.has(id)\n}\n\nexport type TimeTrackingSettingsRecord = Record<string, Record<string, unknown>>\n\n/**\n * The validating schema the settings route parses against, rebuilt per call so a key\n * registered after this module first loaded still validates (the same reason EP-35's\n * export-format enum is an OpenAPI getter).\n */\nexport function buildTimeTrackingSettingsSchema(): z.ZodObject<Record<string, z.ZodTypeAny>> {\n const byGroup = new Map<string, Record<string, z.ZodTypeAny>>()\n const defaultsByGroup = new Map<string, Record<string, unknown>>()\n for (const entry of registry.list()) {\n const shape = byGroup.get(entry.group) ?? {}\n shape[entry.key] = entry.schema.optional().default(entry.default)\n byGroup.set(entry.group, shape)\n const defaults = defaultsByGroup.get(entry.group) ?? {}\n defaults[entry.key] = entry.default\n defaultsByGroup.set(entry.group, defaults)\n }\n const shape: Record<string, z.ZodTypeAny> = {}\n for (const [group, groupShape] of byGroup) {\n shape[group] = z.object(groupShape).optional().default(defaultsByGroup.get(group) ?? {})\n }\n return z.object(shape)\n}\n\n/** The canonical settings object with every registered key at its registered default. */\nexport function buildDefaultTimeTrackingSettings(): TimeTrackingSettingsRecord {\n const result: TimeTrackingSettingsRecord = {}\n for (const entry of registry.list()) {\n const group = result[entry.group] ?? {}\n group[entry.key] = entry.default\n result[entry.group] = group\n }\n return result\n}\n\nfunction readGroup(source: unknown, group: string): Record<string, unknown> | null {\n if (!source || typeof source !== 'object') return null\n const value = (source as Record<string, unknown>)[group]\n if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n return value as Record<string, unknown>\n}\n\n/**\n * Coerce an arbitrary (possibly partial or malformed) settings shape into the canonical\n * one: every registered key present, every absent or invalid value replaced by the\n * registered default.\n */\nexport function normalizeTimeTrackingSettingsRecord(input: unknown): TimeTrackingSettingsRecord {\n const result: TimeTrackingSettingsRecord = {}\n for (const entry of registry.list()) {\n const group = result[entry.group] ?? {}\n const raw = readGroup(input, entry.group)?.[entry.key]\n const parsed = entry.schema.safeParse(raw)\n group[entry.key] = parsed.success ? parsed.data : entry.default\n result[entry.group] = group\n }\n return result\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * EP-42 \u2014 the time-tracking settings key registry.\n *\n * `TIME_TRACKING_SETTING_KEYS` used to be a frozen array, `normalizeTimeTrackingSettings`\n * a hand-written coercion per key, and `staffTimeTrackingSettingsSchema` a literal\n * `z.object`. All three now read this registry, and the eight keys the module shipped\n * are registered here as built-ins \u2014 so with no contribution the read, the write, the\n * validating schema and the defaults are byte-identical to what they were.\n *\n * Storage is `ModuleConfigService` under module id `staff.time_tracking`, one config\n * row per key, named `<group>.<key>` and scoped by **tenant only** (spec \u00A710: time\n * tracking settings are tenant-global; there is no per-organization override and no\n * per-project override). A contributed key inherits exactly that scope \u2014 it is written\n * with `{ tenantId }` and `organization_id` stays null.\n */\n\nimport { z } from 'zod'\nimport { extensionPoints } from '@open-mercato/core/modules/staff/extension-points'\nimport { BUILT_IN_STRATEGY_PRIORITY, createStrategyRegistry } from './registries/registry'\nimport { DEFAULT_ROUNDING_SETTINGS } from './rounding'\n\nexport const TIME_TRACKING_SETTING_KEY_REGISTRY_ID = extensionPoints.hosts.settingKeyRegistry.spotId\n\nexport const TIME_TRACKING_ROUNDING_GROUP = 'rounding'\nexport const TIME_TRACKING_DEFAULTS_GROUP = 'defaults'\nexport const TIME_TRACKING_TARGETS_GROUP = 'targets'\nexport const TIME_TRACKING_WARNINGS_GROUP = 'warnings'\nexport const TIME_TRACKING_ACCESS_GROUP = 'access'\nexport const TIME_TRACKING_SUPPORT_GROUP = 'support'\nexport const DEFAULT_SUPPORT_REFERENCE_PREFIX = 'SUP'\nexport const supportReferencePrefixSchema = z.string().regex(/^[A-Z][A-Z0-9]{1,9}$/)\n\n/**\n * Days past `assigned_end_date` that project access survives (spec D-12). The\n * grace window exists so a consultant rolled off at the end of a month can still\n * log and correct that month's time; `0` ends access the day the assignment ends.\n */\nexport const DEFAULT_ASSIGNMENT_GRACE_DAYS = 14\n\nexport const MAX_ASSIGNMENT_GRACE_DAYS = 365\n\nexport const timeTrackingRoundingUnitMinutesSchema = z.union([\n z.literal(0),\n z.literal(5),\n z.literal(10),\n z.literal(15),\n])\n\nexport const timeTrackingRoundingDirectionSchema = z.enum(['up', 'nearest'])\n\nexport type TimeTrackingSettingKeyInput = {\n /** Group the value lives under in the settings object, e.g. `rounding`. */\n group: string\n /** Leaf name inside the group, e.g. `unitMinutes`. */\n key: string\n /** Validates the stored value; an invalid one falls back to `default`. */\n schema: z.ZodTypeAny\n /** Used when the tenant never stored the key and when the stored value no longer validates. */\n default: unknown\n /** i18n key for the label a settings section renders. Never a literal \u2014 the module holds no user-facing strings. */\n labelKey: string\n /** Higher first in `listTimeTrackingSettingKeys()`; built-ins sit below every contribution. */\n priority?: number\n}\n\nexport type TimeTrackingSettingKeyDefinition = TimeTrackingSettingKeyInput & {\n /** `<group>.<key>` \u2014 the `ModuleConfigService` config name. */\n id: string\n builtIn: boolean\n}\n\nconst registry = createStrategyRegistry<TimeTrackingSettingKeyDefinition>(\n TIME_TRACKING_SETTING_KEY_REGISTRY_ID,\n)\n\nconst builtInIds = new Set<string>()\n\nexport function timeTrackingSettingStorageName(group: string, key: string): string {\n return `${group}.${key}`\n}\n\nfunction registerKey(input: TimeTrackingSettingKeyInput, builtIn: boolean): () => void {\n const group = input.group.trim()\n const key = input.key.trim()\n if (!group || !key) {\n throw new Error('[internal] a time-tracking setting key requires a non-empty group and key')\n }\n const id = timeTrackingSettingStorageName(group, key)\n if (!builtIn && builtInIds.has(id)) {\n throw new Error(`[internal] ${id} is a built-in time-tracking setting key and cannot be replaced`)\n }\n const parsed = input.schema.safeParse(input.default)\n if (!parsed.success) {\n throw new Error(`[internal] the default for the time-tracking setting key ${id} fails its own schema`)\n }\n const definition = { ...input, group, key, id, builtIn }\n if (builtIn) {\n registry.registerBuiltIn(definition)\n return () => {}\n }\n return registry.register(definition)\n}\n\n/**\n * Contribute a tenant-scoped time-tracking setting key. Pair it with a widget on the\n * `staff.time_tracking.settings:sections` injection spot (EP-26) to render it: the spot\n * context carries `values` for every contributed key and a `setValue(id, value)` that\n * writes into the page draft, so the page's own Save round-trips the contribution.\n */\nexport function registerTimeTrackingSettingKey(input: TimeTrackingSettingKeyInput): () => void {\n return registerKey(input, false)\n}\n\nexport function listTimeTrackingSettingKeys(): TimeTrackingSettingKeyDefinition[] {\n return registry.list()\n}\n\nexport function getTimeTrackingSettingKey(id: string | null | undefined): TimeTrackingSettingKeyDefinition | null {\n return registry.get(id)\n}\n\nexport function timeTrackingSettingKeyIds(): string[] {\n return registry.ids()\n}\n\n/** The contributed keys only \u2014 the eight built-ins are rendered by the settings page's own form. */\nexport function contributedTimeTrackingSettingKeys(): TimeTrackingSettingKeyDefinition[] {\n return registry.list().filter((entry) => !entry.builtIn)\n}\n\nexport function timeTrackingSettingGroups(): string[] {\n const groups: string[] = []\n for (const entry of registry.list()) {\n if (!groups.includes(entry.group)) groups.push(entry.group)\n }\n return groups\n}\n\nconst BUILT_IN_SETTING_KEYS: readonly TimeTrackingSettingKeyInput[] = [\n {\n group: TIME_TRACKING_ROUNDING_GROUP,\n key: 'unitMinutes',\n schema: timeTrackingRoundingUnitMinutesSchema,\n default: DEFAULT_ROUNDING_SETTINGS.unitMinutes,\n labelKey: 'staff.time_tracking.settings.rounding.unit',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_ROUNDING_GROUP,\n key: 'direction',\n schema: timeTrackingRoundingDirectionSchema,\n default: DEFAULT_ROUNDING_SETTINGS.direction,\n labelKey: 'staff.time_tracking.settings.rounding.direction',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_DEFAULTS_GROUP,\n key: 'billable',\n schema: z.boolean(),\n default: true,\n labelKey: 'staff.time_tracking.settings.defaults.billable',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_DEFAULTS_GROUP,\n key: 'chainStartFromPreviousEnd',\n schema: z.boolean(),\n default: true,\n labelKey: 'staff.time_tracking.settings.defaults.chainStart',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_TARGETS_GROUP,\n key: 'dailyHours',\n schema: z.number().min(0).max(24).nullable(),\n default: 8,\n labelKey: 'staff.time_tracking.settings.targets.dailyHours',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_WARNINGS_GROUP,\n key: 'overlap',\n schema: z.boolean(),\n default: true,\n labelKey: 'staff.time_tracking.settings.warnings.overlap',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_WARNINGS_GROUP,\n key: 'runningTimer',\n schema: z.boolean(),\n default: true,\n labelKey: 'staff.time_tracking.settings.warnings.runningTimer',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_ACCESS_GROUP,\n key: 'assignmentGraceDays',\n schema: z.number().int().min(0).max(MAX_ASSIGNMENT_GRACE_DAYS),\n default: DEFAULT_ASSIGNMENT_GRACE_DAYS,\n labelKey: 'staff.time_tracking.settings.access.graceDays',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n {\n group: TIME_TRACKING_SUPPORT_GROUP,\n key: 'referencePrefix',\n schema: supportReferencePrefixSchema,\n default: DEFAULT_SUPPORT_REFERENCE_PREFIX,\n labelKey: 'staff.support.settings.referencePrefix',\n priority: BUILT_IN_STRATEGY_PRIORITY,\n },\n]\n\nfor (const input of BUILT_IN_SETTING_KEYS) {\n registerKey(input, true)\n builtInIds.add(timeTrackingSettingStorageName(input.group, input.key))\n}\n\nexport const BUILT_IN_TIME_TRACKING_SETTING_KEY_IDS: readonly string[] = Object.freeze(\n BUILT_IN_SETTING_KEYS.map((input) => timeTrackingSettingStorageName(input.group, input.key)),\n)\n\nexport function isBuiltInTimeTrackingSettingKey(id: string): boolean {\n return builtInIds.has(id)\n}\n\nexport type TimeTrackingSettingsRecord = Record<string, Record<string, unknown>>\n\n/**\n * The validating schema the settings route parses against, rebuilt per call so a key\n * registered after this module first loaded still validates (the same reason EP-35's\n * export-format enum is an OpenAPI getter).\n */\nexport function buildTimeTrackingSettingsSchema(): z.ZodObject<Record<string, z.ZodTypeAny>> {\n const byGroup = new Map<string, Record<string, z.ZodTypeAny>>()\n const defaultsByGroup = new Map<string, Record<string, unknown>>()\n for (const entry of registry.list()) {\n const shape = byGroup.get(entry.group) ?? {}\n shape[entry.key] = entry.schema.optional().default(entry.default)\n byGroup.set(entry.group, shape)\n const defaults = defaultsByGroup.get(entry.group) ?? {}\n defaults[entry.key] = entry.default\n defaultsByGroup.set(entry.group, defaults)\n }\n const shape: Record<string, z.ZodTypeAny> = {}\n for (const [group, groupShape] of byGroup) {\n shape[group] = z.object(groupShape).optional().default(defaultsByGroup.get(group) ?? {})\n }\n return z.object(shape)\n}\n\n/** The canonical settings object with every registered key at its registered default. */\nexport function buildDefaultTimeTrackingSettings(): TimeTrackingSettingsRecord {\n const result: TimeTrackingSettingsRecord = {}\n for (const entry of registry.list()) {\n const group = result[entry.group] ?? {}\n group[entry.key] = entry.default\n result[entry.group] = group\n }\n return result\n}\n\nfunction readGroup(source: unknown, group: string): Record<string, unknown> | null {\n if (!source || typeof source !== 'object') return null\n const value = (source as Record<string, unknown>)[group]\n if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n return value as Record<string, unknown>\n}\n\n/**\n * Coerce an arbitrary (possibly partial or malformed) settings shape into the canonical\n * one: every registered key present, every absent or invalid value replaced by the\n * registered default.\n */\nexport function normalizeTimeTrackingSettingsRecord(input: unknown): TimeTrackingSettingsRecord {\n const result: TimeTrackingSettingsRecord = {}\n for (const entry of registry.list()) {\n const group = result[entry.group] ?? {}\n const raw = readGroup(input, entry.group)?.[entry.key]\n const parsed = entry.schema.safeParse(raw)\n group[entry.key] = parsed.success ? parsed.data : entry.default\n result[entry.group] = group\n }\n return result\n}\n"],
5
+ "mappings": "AAgBA,SAAS,SAAS;AAClB,SAAS,uBAAuB;AAChC,SAAS,4BAA4B,8BAA8B;AACnE,SAAS,iCAAiC;AAEnC,MAAM,wCAAwC,gBAAgB,MAAM,mBAAmB;AAEvF,MAAM,+BAA+B;AACrC,MAAM,+BAA+B;AACrC,MAAM,8BAA8B;AACpC,MAAM,+BAA+B;AACrC,MAAM,6BAA6B;AACnC,MAAM,8BAA8B;AACpC,MAAM,mCAAmC;AACzC,MAAM,+BAA+B,EAAE,OAAO,EAAE,MAAM,sBAAsB;AAO5E,MAAM,gCAAgC;AAEtC,MAAM,4BAA4B;AAElC,MAAM,wCAAwC,EAAE,MAAM;AAAA,EAC3D,EAAE,QAAQ,CAAC;AAAA,EACX,EAAE,QAAQ,CAAC;AAAA,EACX,EAAE,QAAQ,EAAE;AAAA,EACZ,EAAE,QAAQ,EAAE;AACd,CAAC;AAEM,MAAM,sCAAsC,EAAE,KAAK,CAAC,MAAM,SAAS,CAAC;AAuB3E,MAAM,WAAW;AAAA,EACf;AACF;AAEA,MAAM,aAAa,oBAAI,IAAY;AAE5B,SAAS,+BAA+B,OAAe,KAAqB;AACjF,SAAO,GAAG,KAAK,IAAI,GAAG;AACxB;AAEA,SAAS,YAAY,OAAoC,SAA8B;AACrF,QAAM,QAAQ,MAAM,MAAM,KAAK;AAC/B,QAAM,MAAM,MAAM,IAAI,KAAK;AAC3B,MAAI,CAAC,SAAS,CAAC,KAAK;AAClB,UAAM,IAAI,MAAM,2EAA2E;AAAA,EAC7F;AACA,QAAM,KAAK,+BAA+B,OAAO,GAAG;AACpD,MAAI,CAAC,WAAW,WAAW,IAAI,EAAE,GAAG;AAClC,UAAM,IAAI,MAAM,cAAc,EAAE,iEAAiE;AAAA,EACnG;AACA,QAAM,SAAS,MAAM,OAAO,UAAU,MAAM,OAAO;AACnD,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,IAAI,MAAM,4DAA4D,EAAE,uBAAuB;AAAA,EACvG;AACA,QAAM,aAAa,EAAE,GAAG,OAAO,OAAO,KAAK,IAAI,QAAQ;AACvD,MAAI,SAAS;AACX,aAAS,gBAAgB,UAAU;AACnC,WAAO,MAAM;AAAA,IAAC;AAAA,EAChB;AACA,SAAO,SAAS,SAAS,UAAU;AACrC;AAQO,SAAS,+BAA+B,OAAgD;AAC7F,SAAO,YAAY,OAAO,KAAK;AACjC;AAEO,SAAS,8BAAkE;AAChF,SAAO,SAAS,KAAK;AACvB;AAEO,SAAS,0BAA0B,IAAwE;AAChH,SAAO,SAAS,IAAI,EAAE;AACxB;AAEO,SAAS,4BAAsC;AACpD,SAAO,SAAS,IAAI;AACtB;AAGO,SAAS,qCAAyE;AACvF,SAAO,SAAS,KAAK,EAAE,OAAO,CAAC,UAAU,CAAC,MAAM,OAAO;AACzD;AAEO,SAAS,4BAAsC;AACpD,QAAM,SAAmB,CAAC;AAC1B,aAAW,SAAS,SAAS,KAAK,GAAG;AACnC,QAAI,CAAC,OAAO,SAAS,MAAM,KAAK,EAAG,QAAO,KAAK,MAAM,KAAK;AAAA,EAC5D;AACA,SAAO;AACT;AAEA,MAAM,wBAAgE;AAAA,EACpE;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ;AAAA,IACR,SAAS,0BAA0B;AAAA,IACnC,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ;AAAA,IACR,SAAS,0BAA0B;AAAA,IACnC,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ,EAAE,QAAQ;AAAA,IAClB,SAAS;AAAA,IACT,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ,EAAE,QAAQ;AAAA,IAClB,SAAS;AAAA,IACT,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,SAAS;AAAA,IAC3C,SAAS;AAAA,IACT,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ,EAAE,QAAQ;AAAA,IAClB,SAAS;AAAA,IACT,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ,EAAE,QAAQ;AAAA,IAClB,SAAS;AAAA,IACT,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,yBAAyB;AAAA,IAC7D,SAAS;AAAA,IACT,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,OAAO;AAAA,IACP,KAAK;AAAA,IACL,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AACF;AAEA,WAAW,SAAS,uBAAuB;AACzC,cAAY,OAAO,IAAI;AACvB,aAAW,IAAI,+BAA+B,MAAM,OAAO,MAAM,GAAG,CAAC;AACvE;AAEO,MAAM,yCAA4D,OAAO;AAAA,EAC9E,sBAAsB,IAAI,CAAC,UAAU,+BAA+B,MAAM,OAAO,MAAM,GAAG,CAAC;AAC7F;AAEO,SAAS,gCAAgC,IAAqB;AACnE,SAAO,WAAW,IAAI,EAAE;AAC1B;AASO,SAAS,kCAA6E;AAC3F,QAAM,UAAU,oBAAI,IAA0C;AAC9D,QAAM,kBAAkB,oBAAI,IAAqC;AACjE,aAAW,SAAS,SAAS,KAAK,GAAG;AACnC,UAAMA,SAAQ,QAAQ,IAAI,MAAM,KAAK,KAAK,CAAC;AAC3C,IAAAA,OAAM,MAAM,GAAG,IAAI,MAAM,OAAO,SAAS,EAAE,QAAQ,MAAM,OAAO;AAChE,YAAQ,IAAI,MAAM,OAAOA,MAAK;AAC9B,UAAM,WAAW,gBAAgB,IAAI,MAAM,KAAK,KAAK,CAAC;AACtD,aAAS,MAAM,GAAG,IAAI,MAAM;AAC5B,oBAAgB,IAAI,MAAM,OAAO,QAAQ;AAAA,EAC3C;AACA,QAAM,QAAsC,CAAC;AAC7C,aAAW,CAAC,OAAO,UAAU,KAAK,SAAS;AACzC,UAAM,KAAK,IAAI,EAAE,OAAO,UAAU,EAAE,SAAS,EAAE,QAAQ,gBAAgB,IAAI,KAAK,KAAK,CAAC,CAAC;AAAA,EACzF;AACA,SAAO,EAAE,OAAO,KAAK;AACvB;AAGO,SAAS,mCAA+D;AAC7E,QAAM,SAAqC,CAAC;AAC5C,aAAW,SAAS,SAAS,KAAK,GAAG;AACnC,UAAM,QAAQ,OAAO,MAAM,KAAK,KAAK,CAAC;AACtC,UAAM,MAAM,GAAG,IAAI,MAAM;AACzB,WAAO,MAAM,KAAK,IAAI;AAAA,EACxB;AACA,SAAO;AACT;AAEA,SAAS,UAAU,QAAiB,OAA+C;AACjF,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAClD,QAAM,QAAS,OAAmC,KAAK;AACvD,MAAI,CAAC,SAAS,OAAO,UAAU,YAAY,MAAM,QAAQ,KAAK,EAAG,QAAO;AACxE,SAAO;AACT;AAOO,SAAS,oCAAoC,OAA4C;AAC9F,QAAM,SAAqC,CAAC;AAC5C,aAAW,SAAS,SAAS,KAAK,GAAG;AACnC,UAAM,QAAQ,OAAO,MAAM,KAAK,KAAK,CAAC;AACtC,UAAM,MAAM,UAAU,OAAO,MAAM,KAAK,IAAI,MAAM,GAAG;AACrD,UAAM,SAAS,MAAM,OAAO,UAAU,GAAG;AACzC,UAAM,MAAM,GAAG,IAAI,OAAO,UAAU,OAAO,OAAO,MAAM;AACxD,WAAO,MAAM,KAAK,IAAI;AAAA,EACxB;AACA,SAAO;AACT;",
6
6
  "names": ["shape"]
7
7
  }