@open-mercato/core 0.9.0 → 0.9.1-develop.7459.1.99e3accd30

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 (243) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_dictionary_entry/index.js +2 -0
  3. package/dist/generated/entities/customer_dictionary_entry/index.js.map +2 -2
  4. package/dist/generated/entities/customer_interaction/index.js +2 -0
  5. package/dist/generated/entities/customer_interaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/commands/categories.js +1 -1
  9. package/dist/modules/catalog/commands/categories.js.map +2 -2
  10. package/dist/modules/catalog/commands/productDeleteChildren.js +389 -0
  11. package/dist/modules/catalog/commands/productDeleteChildren.js.map +7 -0
  12. package/dist/modules/catalog/commands/products.js +52 -17
  13. package/dist/modules/catalog/commands/products.js.map +2 -2
  14. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +4 -6
  15. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
  16. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +4 -6
  17. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +2 -2
  18. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js +47 -11
  19. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js.map +3 -3
  20. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -1
  21. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  22. package/dist/modules/communication_channels/lib/inbound-message-origin.js +1 -1
  23. package/dist/modules/communication_channels/lib/inbound-message-origin.js.map +2 -2
  24. package/dist/modules/communication_channels/lib/oauth-state.js +4 -1
  25. package/dist/modules/communication_channels/lib/oauth-state.js.map +2 -2
  26. package/dist/modules/communication_channels/lib/use-connect-channel.js +10 -0
  27. package/dist/modules/communication_channels/lib/use-connect-channel.js.map +2 -2
  28. package/dist/modules/customers/api/activity-types/route.js +151 -0
  29. package/dist/modules/customers/api/activity-types/route.js.map +7 -0
  30. package/dist/modules/customers/api/deals/[id]/people/route.js +58 -5
  31. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  32. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
  33. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
  34. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
  35. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  36. package/dist/modules/customers/api/interactions/route.js +3 -0
  37. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  38. package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
  39. package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
  40. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
  41. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +0 -23
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/page.js +15 -17
  45. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  46. package/dist/modules/customers/calendar-event-types.js +548 -0
  47. package/dist/modules/customers/calendar-event-types.js.map +7 -0
  48. package/dist/modules/customers/commands/dictionaries.js +70 -10
  49. package/dist/modules/customers/commands/dictionaries.js.map +2 -2
  50. package/dist/modules/customers/commands/interactions.js +187 -22
  51. package/dist/modules/customers/commands/interactions.js.map +3 -3
  52. package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
  53. package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
  54. package/dist/modules/customers/components/DictionarySettings.js +3 -1
  55. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  56. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
  57. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
  58. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
  59. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  60. package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
  61. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  62. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
  63. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
  64. package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
  65. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  66. package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
  67. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  68. package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
  69. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
  70. package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
  71. package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
  72. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
  73. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
  74. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
  75. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  76. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
  77. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  78. package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
  79. package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
  80. package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
  81. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  82. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
  83. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
  84. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
  85. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
  86. package/dist/modules/customers/components/calendar/types.js +1 -0
  87. package/dist/modules/customers/components/calendar/types.js.map +2 -2
  88. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +78 -343
  89. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  90. package/dist/modules/customers/components/detail/CreatePersonDialog.js +12 -11
  91. package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
  92. package/dist/modules/customers/components/detail/DealForm.js +7 -3
  93. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  94. package/dist/modules/customers/components/detail/DealPeopleSection.js +206 -0
  95. package/dist/modules/customers/components/detail/DealPeopleSection.js.map +7 -0
  96. package/dist/modules/customers/components/detail/LinkedPeopleSection.js +342 -0
  97. package/dist/modules/customers/components/detail/LinkedPeopleSection.js.map +7 -0
  98. package/dist/modules/customers/components/detail/PersonCard.js +5 -2
  99. package/dist/modules/customers/components/detail/PersonCard.js.map +2 -2
  100. package/dist/modules/customers/components/linking/adapters/personAdapter.js +3 -0
  101. package/dist/modules/customers/components/linking/adapters/personAdapter.js.map +2 -2
  102. package/dist/modules/customers/data/entities.js +6 -0
  103. package/dist/modules/customers/data/entities.js.map +2 -2
  104. package/dist/modules/customers/data/validators.js +39 -10
  105. package/dist/modules/customers/data/validators.js.map +2 -2
  106. package/dist/modules/customers/di.js +20 -1
  107. package/dist/modules/customers/di.js.map +2 -2
  108. package/dist/modules/customers/extension-points.js +13 -0
  109. package/dist/modules/customers/extension-points.js.map +2 -2
  110. package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
  111. package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
  112. package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
  113. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  114. package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
  115. package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
  116. package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
  117. package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
  118. package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
  119. package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
  120. package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
  121. package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
  122. package/dist/modules/customers/lib/calendar/timezone.js +111 -0
  123. package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
  124. package/dist/modules/customers/lib/interactionReadModel.js +1 -0
  125. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  126. package/dist/modules/customers/lib/todoCompatibility.js +1 -0
  127. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  128. package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
  129. package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
  130. package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
  131. package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
  132. package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
  133. package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
  134. package/dist/modules/messages/subscribers/message-notification.js +13 -1
  135. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  136. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +201 -53
  137. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
  138. package/dist/modules/payment_gateways/lib/gateway-service.js +37 -11
  139. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  140. package/dist/modules/planner/lib/availabilityMerge.js +101 -38
  141. package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
  142. package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
  143. package/dist/modules/sales/components/documents/LineItemDialog.js +159 -81
  144. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  145. package/dist/modules/sales/components/documents/lineItemUtils.js +7 -0
  146. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  147. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
  148. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  149. package/dist/modules/sync_excel/lib/adapters/customers.js +28 -25
  150. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  151. package/generated/entities/customer_dictionary_entry/index.ts +1 -0
  152. package/generated/entities/customer_interaction/index.ts +1 -0
  153. package/generated/entity-fields-registry.ts +2 -0
  154. package/package.json +9 -8
  155. package/src/modules/auth/i18n/de.json +3 -0
  156. package/src/modules/auth/i18n/en.json +3 -0
  157. package/src/modules/auth/i18n/es.json +3 -0
  158. package/src/modules/auth/i18n/ko.json +3 -0
  159. package/src/modules/auth/i18n/pl.json +3 -0
  160. package/src/modules/catalog/commands/categories.ts +1 -1
  161. package/src/modules/catalog/commands/productDeleteChildren.ts +557 -0
  162. package/src/modules/catalog/commands/products.ts +58 -17
  163. package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +4 -6
  164. package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +4 -6
  165. package/src/modules/communication_channels/api/post/oauth/[provider]/initiate/route.ts +46 -10
  166. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +6 -1
  167. package/src/modules/communication_channels/i18n/de.json +4 -0
  168. package/src/modules/communication_channels/i18n/en.json +4 -0
  169. package/src/modules/communication_channels/i18n/es.json +4 -0
  170. package/src/modules/communication_channels/i18n/ko.json +4 -0
  171. package/src/modules/communication_channels/i18n/pl.json +4 -0
  172. package/src/modules/communication_channels/lib/inbound-message-origin.ts +2 -6
  173. package/src/modules/communication_channels/lib/oauth-state.ts +4 -1
  174. package/src/modules/communication_channels/lib/use-connect-channel.ts +10 -0
  175. package/src/modules/customers/api/activity-types/route.ts +154 -0
  176. package/src/modules/customers/api/deals/[id]/people/route.ts +82 -3
  177. package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
  178. package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
  179. package/src/modules/customers/api/interactions/route.ts +4 -0
  180. package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
  181. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
  182. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +0 -29
  183. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +15 -17
  184. package/src/modules/customers/calendar-event-types.ts +674 -0
  185. package/src/modules/customers/commands/dictionaries.ts +84 -8
  186. package/src/modules/customers/commands/interactions.ts +244 -21
  187. package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
  188. package/src/modules/customers/components/DictionarySettings.tsx +5 -3
  189. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
  190. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
  191. package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
  192. package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
  193. package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
  194. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
  195. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
  196. package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
  197. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
  198. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
  199. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
  200. package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
  201. package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
  202. package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
  203. package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
  204. package/src/modules/customers/components/calendar/types.ts +1 -0
  205. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +94 -388
  206. package/src/modules/customers/components/detail/CreatePersonDialog.tsx +39 -19
  207. package/src/modules/customers/components/detail/DealForm.tsx +7 -3
  208. package/src/modules/customers/components/detail/DealPeopleSection.tsx +256 -0
  209. package/src/modules/customers/components/detail/LinkedPeopleSection.tsx +474 -0
  210. package/src/modules/customers/components/detail/PersonCard.tsx +12 -4
  211. package/src/modules/customers/components/detail/types.ts +1 -0
  212. package/src/modules/customers/components/linking/adapters/personAdapter.tsx +4 -0
  213. package/src/modules/customers/data/entities.ts +8 -1
  214. package/src/modules/customers/data/validators.ts +42 -7
  215. package/src/modules/customers/di.ts +21 -0
  216. package/src/modules/customers/extension-points.ts +13 -0
  217. package/src/modules/customers/i18n/de.json +104 -8
  218. package/src/modules/customers/i18n/en.json +104 -8
  219. package/src/modules/customers/i18n/es.json +104 -8
  220. package/src/modules/customers/i18n/ko.json +104 -8
  221. package/src/modules/customers/i18n/pl.json +104 -8
  222. package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
  223. package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
  224. package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
  225. package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
  226. package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
  227. package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
  228. package/src/modules/customers/lib/calendar/timezone.ts +117 -0
  229. package/src/modules/customers/lib/interactionReadModel.ts +1 -0
  230. package/src/modules/customers/lib/todoCompatibility.ts +2 -0
  231. package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
  232. package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
  233. package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
  234. package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
  235. package/src/modules/messages/subscribers/message-notification.ts +13 -3
  236. package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +243 -62
  237. package/src/modules/payment_gateways/lib/gateway-service.ts +39 -12
  238. package/src/modules/planner/lib/availabilityMerge.ts +106 -41
  239. package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
  240. package/src/modules/sales/components/documents/LineItemDialog.tsx +194 -111
  241. package/src/modules/sales/components/documents/lineItemUtils.ts +18 -0
  242. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +25 -13
  243. package/src/modules/sync_excel/lib/adapters/customers.ts +33 -27
@@ -0,0 +1,258 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { waitForAsyncRegistration } from '@open-mercato/shared/lib/bootstrap/factory'
4
+ import {
5
+ getInjectionRegistryVersion,
6
+ loadInjectionWidgetsForSpot,
7
+ } from '@open-mercato/shared/modules/widgets/injection-loader'
8
+ import {
9
+ calendarEventTypeBehaviorSchema,
10
+ calendarEventTypes,
11
+ getCalendarEventTypes,
12
+ getCalendarEventTypeTombstones,
13
+ resolveCalendarEventType,
14
+ registerWidgetCalendarEventTypeContributions,
15
+ type CalendarEventTypeBehavior,
16
+ type CalendarEventTypeProvenance,
17
+ type EffectiveCalendarEventType,
18
+ } from '../../calendar-event-types'
19
+ import { CustomerDictionaryEntry } from '../../data/entities'
20
+ import { extensionPoints } from '../../extension-points'
21
+
22
+ export type ScopedCalendarEventType = Readonly<{
23
+ key: string
24
+ label: string
25
+ labelKey?: string
26
+ icon?: string | null
27
+ color?: string | null
28
+ behavior: CalendarEventTypeBehavior
29
+ selectable: boolean
30
+ panelKey?: string
31
+ source: string
32
+ provenance: Readonly<Record<string, CalendarEventTypeProvenance>>
33
+ historical: boolean
34
+ fallbackReason?: 'tombstoned' | 'module-unavailable' | 'unknown'
35
+ adminConfigurable: boolean
36
+ isInherited: boolean
37
+ isLocalOverride: boolean
38
+ updatedAt: string | null
39
+ missingCustomFieldsetIds: readonly string[]
40
+ inactiveDictionaryOverride?: boolean
41
+ }>
42
+
43
+ export type CalendarEventTypeCatalog = Readonly<{
44
+ items: readonly ScopedCalendarEventType[]
45
+ fallbackKey: 'meeting'
46
+ }>
47
+
48
+ type ResolveCalendarEventTypesInput = {
49
+ em: EntityManager
50
+ tenantId: string
51
+ organizationId: string
52
+ readableOrganizationIds: readonly string[]
53
+ availableCustomFieldsetIdsFromEntitiesBoundary?: readonly string[]
54
+ }
55
+
56
+ function deepFreeze<T>(value: T): T {
57
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
58
+ Object.freeze(value)
59
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child)
60
+ }
61
+ return value
62
+ }
63
+
64
+ function serializeStaticType(type: EffectiveCalendarEventType): ScopedCalendarEventType {
65
+ return deepFreeze({
66
+ key: type.key,
67
+ label: type.label,
68
+ ...(type.labelKey ? { labelKey: type.labelKey } : {}),
69
+ icon: type.icon ?? null,
70
+ color: type.color ?? null,
71
+ behavior: type.behavior,
72
+ selectable: type.behavior.selectable,
73
+ ...(type.panelKey ? { panelKey: type.panelKey } : {}),
74
+ source: type.provenance.key?.moduleId ?? 'customers',
75
+ provenance: type.provenance,
76
+ historical: type.historical,
77
+ ...(type.fallbackReason ? { fallbackReason: type.fallbackReason } : {}),
78
+ adminConfigurable: type.adminConfigurable !== false,
79
+ isInherited: false,
80
+ isLocalOverride: false,
81
+ updatedAt: null,
82
+ missingCustomFieldsetIds: [],
83
+ })
84
+ }
85
+
86
+ export function resolveBaselineCalendarEventTypes(): CalendarEventTypeCatalog {
87
+ const types: readonly EffectiveCalendarEventType[] = getCalendarEventTypes()
88
+ return deepFreeze({ items: types.map(serializeStaticType), fallbackKey: 'meeting' })
89
+ }
90
+
91
+ let loadedWidgetVersion = -1
92
+ let loadingWidgets: Promise<void> | null = null
93
+
94
+ async function ensureCalendarEventTypeWidgetsLoaded(): Promise<void> {
95
+ await waitForAsyncRegistration()
96
+ const version = getInjectionRegistryVersion()
97
+ if (loadedWidgetVersion === version) return
98
+ if (loadingWidgets) return loadingWidgets
99
+ const pending = (async () => {
100
+ const widgets = await loadInjectionWidgetsForSpot(extensionPoints.hosts.calendarEventTypes.spotId)
101
+ registerWidgetCalendarEventTypeContributions(widgets.map((widget) => {
102
+ return {
103
+ moduleId: widget.moduleId,
104
+ widgetId: widget.metadata.id,
105
+ priority: typeof widget.placement?.priority === 'number' ? widget.placement.priority : 0,
106
+ definitions: 'eventTypes' in widget ? widget.eventTypes : null,
107
+ overrides: 'eventTypeOverrides' in widget ? widget.eventTypeOverrides : undefined,
108
+ patches: 'eventTypePatches' in widget ? widget.eventTypePatches : undefined,
109
+ }
110
+ }))
111
+ loadedWidgetVersion = version
112
+ })()
113
+ loadingWidgets = pending
114
+ try {
115
+ await pending
116
+ } finally {
117
+ if (loadingWidgets === pending) loadingWidgets = null
118
+ }
119
+ }
120
+
121
+ function selectDictionaryRows(
122
+ rows: readonly CustomerDictionaryEntry[],
123
+ organizationId: string,
124
+ readableOrganizationIds: readonly string[],
125
+ ): Map<string, CustomerDictionaryEntry> {
126
+ const orderedOrganizationIds = Array.from(new Set([organizationId, ...readableOrganizationIds]))
127
+ const rank = new Map(orderedOrganizationIds.map((id, index) => [id, index]))
128
+ const sorted = [...rows].sort((left, right) => {
129
+ const rankDelta = (rank.get(left.organizationId) ?? Number.MAX_SAFE_INTEGER) -
130
+ (rank.get(right.organizationId) ?? Number.MAX_SAFE_INTEGER)
131
+ return rankDelta || left.normalizedValue.localeCompare(right.normalizedValue)
132
+ })
133
+ const selected = new Map<string, CustomerDictionaryEntry>()
134
+ for (const row of sorted) {
135
+ const key = row.normalizedValue.trim() || row.value.trim().toLowerCase()
136
+ if (key && !selected.has(key)) selected.set(key, row)
137
+ }
138
+ return selected
139
+ }
140
+
141
+ function isUnchangedShippedDictionaryRow(row: CustomerDictionaryEntry, key: string): boolean {
142
+ const shipped = calendarEventTypes.find((entry) => entry.key === key)
143
+ return !!shipped && !row.activityTypeBehavior && row.label === shipped.label &&
144
+ row.icon === shipped.icon && row.color === shipped.color
145
+ }
146
+
147
+ export async function resolveScopedCalendarEventTypes(
148
+ input: ResolveCalendarEventTypesInput,
149
+ ): Promise<CalendarEventTypeCatalog> {
150
+ await ensureCalendarEventTypeWidgetsLoaded()
151
+ const scopeOrganizationIds = Array.from(new Set([input.organizationId, ...input.readableOrganizationIds]))
152
+ const rows = await findWithDecryption(
153
+ input.em,
154
+ CustomerDictionaryEntry,
155
+ {
156
+ tenantId: input.tenantId,
157
+ organizationId: { $in: scopeOrganizationIds },
158
+ kind: 'activity_type',
159
+ },
160
+ { orderBy: { label: 'asc' } },
161
+ { tenantId: input.tenantId, organizationId: input.organizationId },
162
+ )
163
+ const dictionaryRows = selectDictionaryRows(rows, input.organizationId, scopeOrganizationIds)
164
+ for (const [key, row] of dictionaryRows) {
165
+ if (isUnchangedShippedDictionaryRow(row, key)) dictionaryRows.delete(key)
166
+ }
167
+ const staticCatalog = resolveBaselineCalendarEventTypes()
168
+ const availableFieldsets = input.availableCustomFieldsetIdsFromEntitiesBoundary
169
+ ? new Set(input.availableCustomFieldsetIdsFromEntitiesBoundary)
170
+ : null
171
+ const staticByKey = new Map(staticCatalog.items.map((item) => [item.key, item]))
172
+ const tombstonedKeys = new Set(getCalendarEventTypeTombstones().map((entry) => entry.key))
173
+ const keys = new Set([...staticByKey.keys(), ...[...dictionaryRows.keys()].filter((key) => !tombstonedKeys.has(key))])
174
+ const fallbackBehavior = calendarEventTypes[0]!.behavior
175
+ const items = [...keys].map((key): ScopedCalendarEventType => {
176
+ const base = staticByKey.get(key)
177
+ const row = dictionaryRows.get(key)
178
+ if (!row) {
179
+ if (!availableFieldsets) return base!
180
+ return deepFreeze({
181
+ ...base!,
182
+ missingCustomFieldsetIds: base!.behavior.customFieldsetIds.filter((id) => !availableFieldsets.has(id)),
183
+ })
184
+ }
185
+ if (base?.adminConfigurable === false) {
186
+ return deepFreeze({
187
+ ...base,
188
+ inactiveDictionaryOverride: true,
189
+ })
190
+ }
191
+ const parsedBehavior = row.activityTypeBehavior
192
+ ? calendarEventTypeBehaviorSchema.safeParse(row.activityTypeBehavior)
193
+ : null
194
+ const behavior = parsedBehavior?.success
195
+ ? parsedBehavior.data
196
+ : base?.behavior ?? { ...fallbackBehavior, selectable: true }
197
+ const isLocalOverride = row.organizationId === input.organizationId
198
+ const defaultLabel = calendarEventTypes.find((entry) => entry.key === key)?.label ?? base?.label
199
+ const hasCustomLabel = !!row.label && row.label !== defaultLabel
200
+ return deepFreeze({
201
+ key,
202
+ label: hasCustomLabel ? row.label : base?.label || row.label || row.value,
203
+ ...(!hasCustomLabel && base?.labelKey ? { labelKey: base.labelKey } : {}),
204
+ icon: row.icon ?? base?.icon ?? null,
205
+ color: row.color ?? base?.color ?? null,
206
+ behavior,
207
+ selectable: behavior.selectable,
208
+ ...(base?.panelKey ? { panelKey: base.panelKey } : {}),
209
+ source: base?.source ?? 'dictionary',
210
+ provenance: base?.provenance ?? {},
211
+ historical: false,
212
+ adminConfigurable: true,
213
+ isInherited: !isLocalOverride,
214
+ isLocalOverride,
215
+ updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : null,
216
+ missingCustomFieldsetIds: availableFieldsets
217
+ ? behavior.customFieldsetIds.filter((id) => !availableFieldsets.has(id))
218
+ : [],
219
+ })
220
+ })
221
+ items.sort((left, right) => left.behavior.order - right.behavior.order || left.key.localeCompare(right.key))
222
+ return deepFreeze({ items, fallbackKey: 'meeting' })
223
+ }
224
+
225
+ export function resolveCatalogEventType(
226
+ catalog: CalendarEventTypeCatalog,
227
+ key: string,
228
+ options: { includeHistorical?: boolean } = {},
229
+ ): ScopedCalendarEventType | undefined {
230
+ const normalizedKey = key.trim().toLowerCase()
231
+ const item = catalog.items.find((entry) => entry.key === normalizedKey)
232
+ if (item || !options.includeHistorical) return item
233
+ const registryHistorical = resolveCalendarEventType(key, { includeHistorical: true })
234
+ if (registryHistorical) {
235
+ const serialized = serializeStaticType(registryHistorical)
236
+ return deepFreeze({
237
+ ...serialized,
238
+ selectable: false,
239
+ behavior: { ...serialized.behavior, selectable: false },
240
+ historical: true,
241
+ })
242
+ }
243
+ const fallback = catalog.items.find((entry) => entry.key === catalog.fallbackKey)
244
+ if (!fallback) return undefined
245
+ return deepFreeze({
246
+ ...fallback,
247
+ key,
248
+ label: key,
249
+ labelKey: undefined,
250
+ behavior: { ...fallback.behavior, selectable: false },
251
+ selectable: false,
252
+ historical: true,
253
+ source: 'historical-fallback',
254
+ isInherited: false,
255
+ isLocalOverride: false,
256
+ updatedAt: null,
257
+ })
258
+ }
@@ -0,0 +1,99 @@
1
+ import type { CalendarEventTypeBehavior } from '../../calendar-event-types'
2
+
3
+ export type InteractionCoreValues = Readonly<Record<string, unknown>>
4
+
5
+ const CORE_FIELD_RULES: readonly [keyof CalendarEventTypeBehavior['fields'], readonly string[]][] = [
6
+ ['endTime', ['durationMinutes']],
7
+ ['allDay', ['allDay']],
8
+ ['recurrence', ['recurrenceRule', 'recurrenceEnd']],
9
+ ['priority', ['priority']],
10
+ ]
11
+
12
+ function hasValue(value: unknown): boolean {
13
+ if (value === null || value === undefined || value === '' || value === false) return false
14
+ if (Array.isArray(value)) return value.length > 0
15
+ return true
16
+ }
17
+
18
+ function hasResourceLinks(value: unknown): boolean {
19
+ return Array.isArray(value) && value.some((link) =>
20
+ link !== null && typeof link === 'object' && 'type' in link && link.type === 'resource')
21
+ }
22
+
23
+ export function findInapplicableCoreFields(
24
+ behavior: CalendarEventTypeBehavior,
25
+ values: InteractionCoreValues,
26
+ ): string[] {
27
+ const fields: string[] = []
28
+ for (const [rule, names] of CORE_FIELD_RULES) {
29
+ if (behavior.fields[rule]) continue
30
+ for (const name of names) if (hasValue(values[name])) fields.push(name)
31
+ }
32
+ if (behavior.fields.location === 'none' && hasValue(values.location)) fields.push('location')
33
+ if (behavior.fields.people === 'none' && hasValue(values.participants)) fields.push('participants')
34
+ if (!behavior.fields.resources && hasResourceLinks(values.linkedEntities)) fields.push('linkedEntities')
35
+ return fields
36
+ }
37
+
38
+ export function clearInapplicableCoreFields(
39
+ behavior: CalendarEventTypeBehavior,
40
+ values: InteractionCoreValues,
41
+ ): Record<string, unknown> {
42
+ const cleared: Record<string, unknown> = {}
43
+ for (const field of findInapplicableCoreFields(behavior, values)) {
44
+ if (field === 'linkedEntities') {
45
+ cleared.linkedEntities = (values.linkedEntities as readonly Record<string, unknown>[])
46
+ .filter((link) => link.type !== 'resource')
47
+ } else {
48
+ cleared[field] = null
49
+ }
50
+ }
51
+ return cleared
52
+ }
53
+
54
+ /**
55
+ * Under the selectable-type opt-in an explicit `null` for a field the type hides
56
+ * means "not shown", not "clear it". Dropping it lets a type change judge the
57
+ * stored value, so the destructive clear is confirmed instead of silent.
58
+ */
59
+ export function omitHiddenCoreClears(
60
+ behavior: CalendarEventTypeBehavior,
61
+ patch: InteractionCoreValues,
62
+ ): Record<string, unknown> {
63
+ const result = { ...patch }
64
+ for (const [rule, names] of CORE_FIELD_RULES) {
65
+ if (behavior.fields[rule]) continue
66
+ for (const name of names) if (result[name] === null) delete result[name]
67
+ }
68
+ if (behavior.fields.location === 'none' && result.location === null) delete result.location
69
+ if (behavior.fields.people === 'none' && result.participants === null) delete result.participants
70
+ return result
71
+ }
72
+
73
+ /**
74
+ * `ignoreHiddenClears` belongs to the selectable-type opt-in: only a caller that
75
+ * declares `enforceSelectableType` has its explicit `null` clears of fields the
76
+ * type hides dropped. Retaining stored resource links is unconditional — it
77
+ * protects bookings a partial payload never meant to release.
78
+ */
79
+ export function preserveHiddenCoreValuesOnSameTypeEdit(
80
+ behavior: CalendarEventTypeBehavior,
81
+ current: InteractionCoreValues,
82
+ patch: InteractionCoreValues,
83
+ options: { ignoreHiddenClears?: boolean } = { ignoreHiddenClears: true },
84
+ ): Record<string, unknown> {
85
+ const result = options.ignoreHiddenClears !== false ? omitHiddenCoreClears(behavior, patch) : { ...patch }
86
+ if (!behavior.fields.resources && result.linkedEntities !== undefined) {
87
+ const existingLinks = Array.isArray(current.linkedEntities) ? current.linkedEntities : []
88
+ const retainedResources = existingLinks.filter((link) =>
89
+ link !== null && typeof link === 'object' && 'type' in link && link.type === 'resource')
90
+ const nextLinks = Array.isArray(result.linkedEntities) ? result.linkedEntities : []
91
+ const sentResourceIds = new Set(nextLinks.flatMap((link) =>
92
+ link !== null && typeof link === 'object' && 'type' in link && link.type === 'resource' && 'id' in link ? [link.id] : []))
93
+ result.linkedEntities = [
94
+ ...nextLinks,
95
+ ...retainedResources.filter((link) => !('id' in link) || !sentResourceIds.has(link.id)),
96
+ ]
97
+ }
98
+ return result
99
+ }
@@ -11,6 +11,7 @@ import type {
11
11
  } from '../../components/calendar/types'
12
12
  import { categoryOf } from './categories'
13
13
  import { participantActorKey } from './participantIdentity'
14
+ import { calendarDayEndInstant, calendarDayStartInstant, calendarInstantToWallTime, isCalendarTimezone } from './timezone'
14
15
 
15
16
  const DEFAULT_DURATION_MINUTES = 30
16
17
 
@@ -70,8 +71,11 @@ export function mapInteractionToCalendarItem(
70
71
 
71
72
  const allDay = payload.allDay === true
72
73
  const durationMinutes = payload.durationMinutes ?? DEFAULT_DURATION_MINUTES
73
- const start = allDay ? startOfDay(parsedStart) : parsedStart
74
- const end = allDay ? endOfDay(parsedStart) : addMinutes(parsedStart, durationMinutes)
74
+ const eventDate = allDay && isCalendarTimezone(payload.timezone) ? calendarInstantToWallTime(parsedStart, payload.timezone).date : null
75
+ const start = eventDate ? calendarDayStartInstant(eventDate, payload.timezone as string) : allDay ? startOfDay(parsedStart) : parsedStart
76
+ const end = eventDate ? calendarDayEndInstant(eventDate, payload.timezone as string) : allDay ? endOfDay(parsedStart) : addMinutes(parsedStart, durationMinutes)
77
+
78
+ if (!start || !end) return null
75
79
 
76
80
  const location = payload.location ?? null
77
81
  const platform = detectPlatform(location)
@@ -1,7 +1,9 @@
1
1
  import { addDays } from 'date-fns/addDays'
2
+ import { calendarDayEndInstant, calendarDayStartInstant, calendarInstantToWallTime, calendarRecurrenceWallTimeToInstant, isCalendarTimezone } from './timezone'
2
3
  import type { CalendarItem, CalendarRange } from '../../components/calendar/types'
3
4
 
4
5
  const MAX_OCCURRENCES_PER_WINDOW = 100
6
+ const DAY_MS = 24 * 60 * 60 * 1000
5
7
 
6
8
  const WEEKDAY_TOKENS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const
7
9
 
@@ -92,15 +94,70 @@ function parseRecurrenceEndTime(rawRecurrenceEnd: string | null | undefined): nu
92
94
 
93
95
  function occursOn(date: Date, rule: ParsedRecurrenceRule, seriesStartWeekday: number): boolean {
94
96
  if (rule.freq === 'DAILY') return true
95
- const allowedWeekdays = rule.byDay ?? [seriesStartWeekday]
97
+ const allowedWeekdays = [...new Set(rule.byDay ?? [seriesStartWeekday])]
96
98
  return allowedWeekdays.includes(date.getDay())
97
99
  }
98
100
 
101
+ function countMatchingDays(startDay: number, endDay: number, rule: ParsedRecurrenceRule, seriesStartWeekday: number): number {
102
+ const dayCount = Math.max(0, endDay - startDay)
103
+ if (rule.freq === 'DAILY') return dayCount
104
+ const allowedWeekdays = [...new Set(rule.byDay ?? [seriesStartWeekday])]
105
+ const fullWeeks = Math.floor(dayCount / 7)
106
+ let matches = fullWeeks * allowedWeekdays.length
107
+ const remainder = dayCount % 7
108
+ const startWeekday = new Date(startDay * DAY_MS).getUTCDay()
109
+ for (let offset = 0; offset < remainder; offset += 1) {
110
+ if (allowedWeekdays.includes((startWeekday + offset) % 7)) matches += 1
111
+ }
112
+ return matches
113
+ }
114
+
115
+ function utcDay(date: Date): number {
116
+ return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS)
117
+ }
118
+
119
+ function expandZonedOccurrences(item: CalendarItem, range: CalendarRange, rule: ParsedRecurrenceRule, timezone: string): CalendarItem[] {
120
+ const startWall = calendarInstantToWallTime(item.start, timezone)
121
+ const cursor = new Date(`${startWall.date}T00:00:00Z`)
122
+ const seriesStartDay = Math.floor(cursor.getTime() / DAY_MS)
123
+ const earliestRelevant = new Date(range.from.getTime() - Math.max(0, item.end.getTime() - item.start.getTime()))
124
+ const earliestWallDate = calendarInstantToWallTime(earliestRelevant, timezone).date
125
+ const earliestDay = Math.floor(new Date(`${earliestWallDate}T00:00:00Z`).getTime() / DAY_MS)
126
+ const finalDate = calendarInstantToWallTime(range.to, timezone).date
127
+ const seriesWeekday = cursor.getUTCDay()
128
+ const duration = item.end.getTime() - item.start.getTime()
129
+ const recurrenceEnd = typeof item.raw.recurrenceEnd === 'string' ? new Date(item.raw.recurrenceEnd).getTime() : null
130
+ const occurrences: CalendarItem[] = []
131
+ let occurrenceIndex = 0
132
+ if (earliestDay > seriesStartDay) {
133
+ occurrenceIndex = countMatchingDays(seriesStartDay, earliestDay, rule, seriesWeekday)
134
+ cursor.setUTCDate(cursor.getUTCDate() + earliestDay - seriesStartDay)
135
+ }
136
+ while (cursor.toISOString().slice(0, 10) <= finalDate) {
137
+ const date = cursor.toISOString().slice(0, 10)
138
+ const start = item.allDay ? calendarDayStartInstant(date, timezone) : calendarRecurrenceWallTimeToInstant(date, startWall.time, timezone)
139
+ if (start && ((rule.until && start > rule.until) || (recurrenceEnd !== null && start.getTime() > recurrenceEnd))) break
140
+ const matches = rule.freq === 'DAILY' || (rule.byDay ?? [seriesWeekday]).includes(cursor.getUTCDay())
141
+ if (start && matches) {
142
+ if (rule.count !== null && occurrenceIndex >= rule.count) break
143
+ const end = item.allDay ? calendarDayEndInstant(date, timezone) : new Date(start.getTime() + duration)
144
+ if (end && start <= range.to && end > range.from) {
145
+ occurrences.push({ ...item, id: `${item.id}:${occurrenceIndex}`, start, end, isRecurringOccurrence: true })
146
+ if (occurrences.length >= MAX_OCCURRENCES_PER_WINDOW) break
147
+ }
148
+ occurrenceIndex += 1
149
+ }
150
+ cursor.setUTCDate(cursor.getUTCDate() + 1)
151
+ }
152
+ return occurrences
153
+ }
154
+
99
155
  export function expandOccurrences(item: CalendarItem, range: CalendarRange): CalendarItem[] {
100
156
  const rawRule = item.raw.recurrenceRule
101
157
  if (typeof rawRule !== 'string' || rawRule.trim().length === 0) return [item]
102
158
  const rule = parseRecurrenceRule(rawRule)
103
159
  if (!rule) return [item]
160
+ if (isCalendarTimezone(item.raw.timezone)) return expandZonedOccurrences(item, range, rule, item.raw.timezone)
104
161
 
105
162
  const durationMs = item.end.getTime() - item.start.getTime()
106
163
  const recurrenceEndTime = parseRecurrenceEndTime(item.raw.recurrenceEnd)
@@ -110,6 +167,13 @@ export function expandOccurrences(item: CalendarItem, range: CalendarRange): Cal
110
167
  const occurrences: CalendarItem[] = []
111
168
  let occurrenceIndex = 0
112
169
  let cursor = new Date(item.start)
170
+ const seriesStartDay = utcDay(cursor)
171
+ const earliestRelevant = new Date(range.from.getTime() - Math.max(0, durationMs))
172
+ const earliestDay = utcDay(earliestRelevant)
173
+ if (earliestDay > seriesStartDay) {
174
+ occurrenceIndex = countMatchingDays(seriesStartDay, earliestDay, rule, seriesStartWeekday)
175
+ cursor = addDays(cursor, earliestDay - seriesStartDay)
176
+ }
113
177
 
114
178
  while (cursor.getTime() <= range.to.getTime()) {
115
179
  if (untilTime !== null && cursor.getTime() > untilTime) break
@@ -0,0 +1,117 @@
1
+ const formatters = new Map<string, Intl.DateTimeFormat>()
2
+
3
+ function parseWallTime(date: string, time: string): Date | null {
4
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(time)) return null
5
+ const wall = new Date(`${date}T${time}:00Z`)
6
+ if (!Number.isFinite(wall.getTime()) || wall.toISOString().slice(0, 16) !== `${date}T${time}`) return null
7
+ return wall
8
+ }
9
+
10
+ function calendarTimezoneOffsets(wall: Date, timezone: string): number[] {
11
+ const offsets = new Set<number>()
12
+ for (const hour of [-36, -12, 0, 12, 36]) {
13
+ const sample = new Date(wall.getTime() + hour * 3600000)
14
+ const local = calendarInstantToWallTime(sample, timezone)
15
+ offsets.add(new Date(`${local.date}T${local.time}:00Z`).getTime() - sample.getTime())
16
+ }
17
+ return [...offsets]
18
+ }
19
+
20
+ export function isCalendarTimezone(value: unknown): value is string {
21
+ if (typeof value !== 'string' || !value.trim() || value.length > 120 || /^[+-]/.test(value)) return false
22
+ try {
23
+ formatter(value).format()
24
+ return true
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ /**
31
+ * IANA zone ids are case-insensitive, so `europe/warsaw` and `Europe/Warsaw` are
32
+ * the same zone and would otherwise be stored — and listed — as two options.
33
+ * Canonicalize on write so the stored value matches the catalog spelling.
34
+ */
35
+ export function canonicalCalendarTimezone(value: unknown): string | null {
36
+ if (!isCalendarTimezone(value)) return null
37
+ const resolved = formatter(value).resolvedOptions().timeZone
38
+ return isCalendarTimezone(resolved) ? resolved : value
39
+ }
40
+
41
+ export function defaultCalendarTimezone(): string {
42
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
43
+ return isCalendarTimezone(timezone) ? timezone : 'UTC'
44
+ }
45
+
46
+ function formatter(timezone: string): Intl.DateTimeFormat {
47
+ let result = formatters.get(timezone)
48
+ if (!result) {
49
+ result = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' })
50
+ if (formatters.size >= 64) formatters.delete(formatters.keys().next().value!)
51
+ formatters.set(timezone, result)
52
+ }
53
+ return result
54
+ }
55
+
56
+ export function calendarInstantToWallTime(instant: Date, timezone: string): { date: string; time: string } {
57
+ const parts = formatter(timezone).formatToParts(instant)
58
+ const part = (name: string) => parts.find((entry) => entry.type === name)!.value
59
+ return { date: `${part('year')}-${part('month')}-${part('day')}`, time: `${part('hour')}:${part('minute')}` }
60
+ }
61
+
62
+ export function calendarWallTimeToInstant(date: string, time: string, timezone: string): Date | null {
63
+ if (!isCalendarTimezone(timezone)) return null
64
+ const wall = parseWallTime(date, time)
65
+ if (!wall) return null
66
+ const candidates = calendarTimezoneOffsets(wall, timezone).map((offset) => new Date(wall.getTime() - offset))
67
+ .filter((instant) => {
68
+ const local = calendarInstantToWallTime(instant, timezone)
69
+ return local.date === date && local.time === time
70
+ }).sort((left, right) => left.getTime() - right.getTime())
71
+ return candidates[0] ?? null
72
+ }
73
+
74
+ export function calendarRecurrenceWallTimeToInstant(date: string, time: string, timezone: string): Date | null {
75
+ const exact = calendarWallTimeToInstant(date, time, timezone)
76
+ if (exact) return exact
77
+ if (!isCalendarTimezone(timezone) || !calendarDayStartInstant(date, timezone)) return null
78
+ const wall = parseWallTime(date, time)
79
+ if (!wall) return null
80
+ const offsets = calendarTimezoneOffsets(wall, timezone)
81
+ if (offsets.length < 2) return null
82
+ return new Date(wall.getTime() - Math.min(...offsets))
83
+ }
84
+
85
+ export function calendarDayStartInstant(date: string, timezone: string): Date | null {
86
+ if (!isCalendarTimezone(timezone) || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null
87
+ const anchor = new Date(`${date}T00:00:00Z`)
88
+ if (!Number.isFinite(anchor.getTime()) || anchor.toISOString().slice(0, 10) !== date) return null
89
+ const midnight = calendarWallTimeToInstant(date, '00:00', timezone)
90
+ if (midnight) return midnight
91
+ let lower = anchor.getTime() - 36 * 3600000
92
+ let upper = anchor.getTime() + 36 * 3600000
93
+ while (lower < upper) {
94
+ const middle = lower + Math.floor((upper - lower) / 2)
95
+ if (calendarInstantToWallTime(new Date(middle), timezone).date < date) lower = middle + 1
96
+ else upper = middle
97
+ }
98
+ const boundary = new Date(lower)
99
+ return calendarInstantToWallTime(boundary, timezone).date === date ? boundary : null
100
+ }
101
+
102
+ export function calendarDayEndInstant(date: string, timezone: string): Date | null {
103
+ if (!calendarDayStartInstant(date, timezone)) return null
104
+ const nextDate = new Date(`${date}T00:00:00Z`)
105
+ for (let skippedDays = 0; skippedDays < 3; skippedDays += 1) {
106
+ nextDate.setUTCDate(nextDate.getUTCDate() + 1)
107
+ const nextBoundary = calendarDayStartInstant(nextDate.toISOString().slice(0, 10), timezone)
108
+ if (nextBoundary) return new Date(nextBoundary.getTime() - 1)
109
+ }
110
+ return null
111
+ }
112
+
113
+ export function calendarTimezoneOptions(storedTimezone?: string | null): string[] {
114
+ const intl = Intl as typeof Intl & { supportedValuesOf?: (key: 'timeZone') => string[] }
115
+ const stored = isCalendarTimezone(storedTimezone) ? [storedTimezone] : []
116
+ return [...new Set(['UTC', defaultCalendarTimezone(), ...stored, ...(intl.supportedValuesOf?.('timeZone') ?? [])])]
117
+ }
@@ -189,6 +189,7 @@ export async function hydrateCanonicalInteractions({
189
189
  appearanceColor: interaction.appearanceColor ?? null,
190
190
  source: interaction.source ?? null,
191
191
  duration: interaction.durationMinutes ?? null,
192
+ timezone: interaction.timezone ?? null,
192
193
  location: interaction.location ?? null,
193
194
  allDay: interaction.allDay ?? null,
194
195
  recurrenceRule: interaction.recurrenceRule ?? null,
@@ -28,6 +28,7 @@ export type CustomerTodoRow = {
28
28
  todoSeverity?: string | null
29
29
  todoDescription?: string | null
30
30
  todoDueAt?: string | null
31
+ todoTimezone?: string | null
31
32
  todoCustomValues?: Record<string, unknown> | null
32
33
  todoOrganizationId: string | null
33
34
  todoUpdatedAt?: string | null
@@ -640,6 +641,7 @@ export function mapInteractionRecordToTodoRow(
640
641
  : null,
641
642
  todoDescription: interaction.body ?? null,
642
643
  todoDueAt: interaction.scheduledAt ?? null,
644
+ todoTimezone: interaction.timezone ?? null,
643
645
  todoCustomValues: Object.keys(customValues).length > 0 ? customValues : null,
644
646
  todoOrganizationId: interaction.organizationId ?? null,
645
647
  todoUpdatedAt: interaction.updatedAt ?? null,
@@ -2528,6 +2528,23 @@
2528
2528
  "name": "customer_dictionary_entries",
2529
2529
  "schema": "public",
2530
2530
  "columns": {
2531
+ "activity_type_behavior": {
2532
+ "name": "activity_type_behavior",
2533
+ "type": "jsonb",
2534
+ "unsigned": false,
2535
+ "autoincrement": false,
2536
+ "primary": false,
2537
+ "nullable": true,
2538
+ "unique": false,
2539
+ "length": null,
2540
+ "precision": null,
2541
+ "scale": null,
2542
+ "default": null,
2543
+ "comment": null,
2544
+ "collation": null,
2545
+ "enumItems": [],
2546
+ "mappedType": "json"
2547
+ },
2531
2548
  "color": {
2532
2549
  "name": "color",
2533
2550
  "type": "text",
@@ -4435,6 +4452,23 @@
4435
4452
  "collation": null,
4436
4453
  "enumItems": [],
4437
4454
  "mappedType": "text"
4455
+ },
4456
+ "timezone": {
4457
+ "name": "timezone",
4458
+ "type": "text",
4459
+ "unsigned": false,
4460
+ "autoincrement": false,
4461
+ "primary": false,
4462
+ "nullable": true,
4463
+ "unique": false,
4464
+ "length": null,
4465
+ "precision": null,
4466
+ "scale": null,
4467
+ "default": null,
4468
+ "comment": null,
4469
+ "collation": null,
4470
+ "enumItems": [],
4471
+ "mappedType": "text"
4438
4472
  }
4439
4473
  },
4440
4474
  "indexes": [
@@ -0,0 +1,15 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260928175058_customers extends Migration {
4
+
5
+ override name = 'Migration20260928175058_customers';
6
+
7
+ override up(): void | Promise<void> {
8
+ this.addSql(`alter table "customer_dictionary_entries" add "activity_type_behavior" jsonb null;`);
9
+ }
10
+
11
+ override down(): void | Promise<void> {
12
+ this.addSql(`alter table "customer_dictionary_entries" drop column "activity_type_behavior";`);
13
+ }
14
+
15
+ }