@oneuptime/common 12.0.1 → 12.0.2

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 (205) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  5. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  6. package/Models/DatabaseModels/StatusPage.ts +95 -0
  7. package/Server/API/TeamMemberAPI.ts +85 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  13. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  14. package/Server/Services/BillingService.ts +600 -51
  15. package/Server/Services/CephClusterService.ts +47 -5
  16. package/Server/Services/CephResourceService.ts +43 -4
  17. package/Server/Services/CloudResourceService.ts +47 -5
  18. package/Server/Services/DatabaseService.ts +87 -6
  19. package/Server/Services/DockerHostService.ts +47 -5
  20. package/Server/Services/DockerResourceService.ts +62 -8
  21. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  22. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  23. package/Server/Services/HostService.ts +48 -5
  24. package/Server/Services/IoTDeviceService.ts +1 -17
  25. package/Server/Services/IoTFleetService.ts +47 -5
  26. package/Server/Services/KubernetesClusterService.ts +48 -5
  27. package/Server/Services/KubernetesContainerService.ts +50 -9
  28. package/Server/Services/KubernetesResourceService.ts +53 -7
  29. package/Server/Services/LabelService.ts +106 -1
  30. package/Server/Services/MonitorService.ts +57 -0
  31. package/Server/Services/PodmanHostService.ts +47 -5
  32. package/Server/Services/PodmanResourceService.ts +62 -8
  33. package/Server/Services/ProjectService.ts +165 -35
  34. package/Server/Services/ProxmoxClusterService.ts +47 -5
  35. package/Server/Services/ProxmoxResourceService.ts +43 -4
  36. package/Server/Services/RumApplicationService.ts +48 -5
  37. package/Server/Services/ServerlessFunctionService.ts +48 -5
  38. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  39. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  40. package/Server/Services/StatusPageService.ts +95 -36
  41. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  42. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  43. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  44. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  45. package/Server/Types/Database/QueryHelper.ts +17 -32
  46. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  47. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  48. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  49. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  50. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  51. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  52. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  53. package/Tests/Server/Services/BillingService.test.ts +78 -3
  54. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  55. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  56. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  57. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  58. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  59. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  60. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  61. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  62. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  63. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  64. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  65. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  66. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  67. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  68. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  69. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  70. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  71. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  72. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  73. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  74. package/Tests/Types/Events/Recurring.test.ts +157 -0
  75. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  76. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  77. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  78. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  79. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  80. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  81. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  82. package/Types/Date.ts +112 -0
  83. package/Types/Events/Recurring.ts +99 -1
  84. package/Types/StatusPage/StatusPageReport.ts +11 -0
  85. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  86. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  87. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  88. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  89. package/UI/Utils/TeamMembersByUser.ts +302 -0
  90. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  91. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  92. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  93. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  95. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  97. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  99. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  101. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  103. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  104. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  105. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  113. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  114. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  116. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  117. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  118. package/build/dist/Server/Services/BillingService.js +466 -36
  119. package/build/dist/Server/Services/BillingService.js.map +1 -1
  120. package/build/dist/Server/Services/CephClusterService.js +42 -5
  121. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  122. package/build/dist/Server/Services/CephResourceService.js +28 -3
  123. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  124. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  125. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  126. package/build/dist/Server/Services/DatabaseService.js +69 -2
  127. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  128. package/build/dist/Server/Services/DockerHostService.js +42 -5
  129. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  130. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  131. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  132. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  133. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  134. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  135. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  136. package/build/dist/Server/Services/HostService.js +43 -5
  137. package/build/dist/Server/Services/HostService.js.map +1 -1
  138. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  139. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  140. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  141. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  142. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  143. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  144. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  145. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  146. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  147. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  148. package/build/dist/Server/Services/LabelService.js +92 -0
  149. package/build/dist/Server/Services/LabelService.js.map +1 -1
  150. package/build/dist/Server/Services/MonitorService.js +47 -0
  151. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  152. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  153. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  154. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  155. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  156. package/build/dist/Server/Services/ProjectService.js +135 -21
  157. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  158. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  159. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  160. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  161. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  162. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  163. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  164. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  165. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  166. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  167. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  168. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  169. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  170. package/build/dist/Server/Services/StatusPageService.js +69 -46
  171. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  172. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  173. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  174. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  175. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  176. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  177. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  178. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  179. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  180. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  181. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  182. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  183. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  184. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  185. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  186. package/build/dist/Types/Date.js +68 -0
  187. package/build/dist/Types/Date.js.map +1 -1
  188. package/build/dist/Types/Events/Recurring.js +63 -0
  189. package/build/dist/Types/Events/Recurring.js.map +1 -1
  190. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  191. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  192. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  193. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  194. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  195. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  196. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  197. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  198. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  199. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  200. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  201. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  202. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  203. package/jest.config.json +2 -0
  204. package/package.json +1 -1
  205. package/tsconfig.json +12 -1
@@ -1,5 +1,5 @@
1
1
  import DatabaseProperty from "../Database/DatabaseProperty";
2
- import OneUptimeDate from "../Date";
2
+ import OneUptimeDate, { CalendarUnit } from "../Date";
3
3
  import BadDataException from "../Exception/BadDataException";
4
4
  import { JSONArray, JSONObject, ObjectType } from "../JSON";
5
5
  import JSONFunctions from "../JSONFunctions";
@@ -109,6 +109,104 @@ export default class Recurring extends DatabaseProperty {
109
109
  return nextDate;
110
110
  }
111
111
 
112
+ /*
113
+ * The calendar unit this interval is expressed in, for the date helpers that
114
+ * do calendar (rather than fixed-millisecond) arithmetic.
115
+ */
116
+ public static toCalendarUnit(intervalType: EventInterval): CalendarUnit {
117
+ switch (intervalType) {
118
+ case EventInterval.Hour:
119
+ return "hour";
120
+ case EventInterval.Day:
121
+ return "day";
122
+ case EventInterval.Week:
123
+ return "week";
124
+ case EventInterval.Month:
125
+ return "month";
126
+ case EventInterval.Year:
127
+ return "year";
128
+ default:
129
+ throw new BadDataException("Invalid Interval Type: " + intervalType);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The first occurrence of `recurring` (anchored at `startDate`) strictly after
135
+ * `afterDate`, stepping in whole CALENDAR units in `timezone`.
136
+ *
137
+ * getNextDate() approximates a month as 30.4375 days and a year as 365.25
138
+ * days, which is fine for hours/days/weeks but makes a monthly recurrence
139
+ * walk backwards through the calendar: anchored on 1 Jul it fires on 31 Jul,
140
+ * then 30 Aug, and eventually skips a month entirely. This steps 1 Jul ->
141
+ * 1 Aug -> 1 Sep instead, and holds the local time of day across DST.
142
+ *
143
+ * A start date already after `afterDate` is returned unchanged - the first
144
+ * occurrence has not happened yet.
145
+ */
146
+ public static getNextDateAfter(data: {
147
+ startDate: Date;
148
+ recurring: Recurring;
149
+ afterDate: Date;
150
+ timezone?: string | undefined;
151
+ }): Date {
152
+ const startDate: Date = OneUptimeDate.fromString(data.startDate);
153
+ const recurring: Recurring = Recurring.fromJSON(data.recurring);
154
+ const unit: CalendarUnit = Recurring.toCalendarUnit(recurring.intervalType);
155
+
156
+ const intervalCount: number = Math.max(
157
+ 1,
158
+ Math.floor(recurring.intervalCount.toNumber()),
159
+ );
160
+
161
+ if (startDate.getTime() > data.afterDate.getTime()) {
162
+ return startDate;
163
+ }
164
+
165
+ /*
166
+ * Jump most of the way in one step so a status page left disabled for a year
167
+ * does not loop 365 times, then walk the remainder. The estimate can only be
168
+ * off by a single interval either way (calendar units are not uniform), so
169
+ * the two loops below run a handful of times at most.
170
+ */
171
+ const elapsedUnits: number = OneUptimeDate.getCalendarUnitsBetween(
172
+ startDate,
173
+ data.afterDate,
174
+ unit,
175
+ data.timezone,
176
+ );
177
+
178
+ let intervals: number = Math.max(
179
+ 0,
180
+ Math.floor(elapsedUnits / intervalCount),
181
+ );
182
+
183
+ type OccurrenceFunction = (intervalsToAdd: number) => Date;
184
+
185
+ const occurrence: OccurrenceFunction = (intervalsToAdd: number): Date => {
186
+ return OneUptimeDate.addRemoveCalendarUnits(
187
+ startDate,
188
+ unit,
189
+ intervalsToAdd * intervalCount,
190
+ data.timezone,
191
+ );
192
+ };
193
+
194
+ // Overshot - come back until we are at or before the cutoff.
195
+ while (
196
+ intervals > 0 &&
197
+ occurrence(intervals - 1).getTime() > data.afterDate.getTime()
198
+ ) {
199
+ intervals--;
200
+ }
201
+
202
+ // Undershot - advance until we are strictly after it.
203
+ while (occurrence(intervals).getTime() <= data.afterDate.getTime()) {
204
+ intervals++;
205
+ }
206
+
207
+ return occurrence(intervals);
208
+ }
209
+
112
210
  private data: RecurringData = Recurring.getDefaultRotationData();
113
211
 
114
212
  public get intervalType(): EventInterval {
@@ -85,6 +85,17 @@ export interface StatusPageReportRow {
85
85
 
86
86
  export interface StatusPageReport {
87
87
  reportDates: string; // start date and end date in string. e.g. "01 July 2021 - 14 July 2021"
88
+ /*
89
+ * How a sentence refers to the period this report covers - "the last 30
90
+ * days", "July 2026", "the week of Jul 27, 2026". `reportDates` is the raw
91
+ * range; this is the phrase that goes after "your status summary for ...".
92
+ */
93
+ reportPeriodName: string;
94
+ // The two ends of the window on their own, for templates that lay them out.
95
+ reportStartDate: string;
96
+ reportEndDate: string;
97
+ // The IANA zone every date above was resolved in, e.g. "America/New_York".
98
+ reportTimezone: string;
88
99
  totalResources: number;
89
100
  totalIncidents: number;
90
101
  averageUptimePercent: string;
@@ -0,0 +1,21 @@
1
+ /*
2
+ * What stretch of time a scheduled status page report covers.
3
+ *
4
+ * The report used to only ever cover "the last N days counted back from the
5
+ * moment the email went out", which can never line up with a calendar month -
6
+ * a monthly report sent on 1 Aug covered 2 Jul - 1 Aug rather than the July
7
+ * everyone reading it expects, and consecutive reports silently overlapped or
8
+ * left gaps whenever the day count did not match the send frequency.
9
+ */
10
+ enum StatusPageReportPeriodType {
11
+ // The window ends when the email is sent and runs back `reportDataInDays`.
12
+ Rolling = "Rolling",
13
+ /*
14
+ * The last whole calendar period before the email is sent, sized by the send
15
+ * frequency: a report sent monthly covers 1 Jul 00:00 - 31 Jul 23:59:59.999
16
+ * in the status page's report timezone.
17
+ */
18
+ PreviousCalendarPeriod = "Previous Calendar Period",
19
+ }
20
+
21
+ export default StatusPageReportPeriodType;
@@ -5,6 +5,7 @@ import DropdownValueBadge from "../Dropdown/DropdownValueBadge";
5
5
  import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
6
6
  import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
7
7
  import CustomFieldType from "../../../Types/CustomField/CustomFieldType";
8
+ import { CustomFieldDefinition } from "../../../Types/CustomField/CustomFieldDefinition";
8
9
  import {
9
10
  CustomFieldDropdownOption,
10
11
  parseCustomFieldDropdownOptions,
@@ -45,12 +46,7 @@ import React, { ReactElement } from "react";
45
46
  * fall-through text branch understands a dotted key.
46
47
  */
47
48
 
48
- export interface CustomFieldDefinition {
49
- name: string;
50
- description?: string | undefined;
51
- customFieldType?: CustomFieldType | undefined;
52
- dropdownOptions?: string | undefined;
53
- }
49
+ export type { CustomFieldDefinition } from "../../../Types/CustomField/CustomFieldDefinition";
54
50
 
55
51
  export const CustomFieldsColumnKey: string = "customFields";
56
52
 
@@ -55,18 +55,37 @@ const useCustomFieldColumns: UseCustomFieldColumnsFunction = <
55
55
  const [definitions, setDefinitions] = useState<Array<CustomFieldDefinition>>(
56
56
  [],
57
57
  );
58
- const [isLoading, setIsLoading] = useState<boolean>(false);
59
- const [error, setError] = useState<string>("");
60
58
 
61
59
  const projectId: ObjectID | null =
62
60
  data.projectId ?? ProjectUtil.getCurrentProjectId();
63
61
 
62
+ /*
63
+ * Starts true whenever a request is actually going to happen, rather than
64
+ * false-until-the-effect-runs.
65
+ *
66
+ * Columns did not care — they only ever appear — but a caller that has to
67
+ * distinguish "no custom fields" from "not yet" does. The facet bar is one:
68
+ * on the first render it must know that a chip restored from a shared link
69
+ * may still be on its way, or it drops the link's filter before the request
70
+ * has even been sent.
71
+ */
72
+ const [isLoading, setIsLoading] = useState<boolean>(
73
+ Boolean(data.customFieldsModelType && projectId),
74
+ );
75
+ const [error, setError] = useState<string>("");
76
+
64
77
  const projectIdString: string = projectId?.toString() || "";
65
78
  const modelName: string = customFieldsModelType?.name || "";
66
79
 
67
80
  useEffect(() => {
68
81
  if (!customFieldsModelType || !projectId) {
69
82
  setDefinitions([]);
83
+ /*
84
+ * Nothing to wait for. Said explicitly because the initial state is now
85
+ * "loading" whenever a request is expected, and a model type that goes
86
+ * away between renders would otherwise leave it stuck there.
87
+ */
88
+ setIsLoading(false);
70
89
  return;
71
90
  }
72
91
 
@@ -0,0 +1,305 @@
1
+ import ModelAPI, { ListResult, RequestOptions } from "./ModelAPI";
2
+ import TeamMembersByUser, { ProjectUserRow } from "../TeamMembersByUser";
3
+ import API from "../API/API";
4
+ import { APP_API_URL } from "../../Config";
5
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
6
+ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
7
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
8
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
9
+ import URL from "../../../Types/API/URL";
10
+ import { JSONObject } from "../../../Types/JSON";
11
+ import GroupBy from "../../../Types/BaseDatabase/GroupBy";
12
+ import Includes from "../../../Types/BaseDatabase/Includes";
13
+ import Query from "../../../Types/BaseDatabase/Query";
14
+ import Select from "../../../Types/BaseDatabase/Select";
15
+ import Sort from "../../../Types/BaseDatabase/Sort";
16
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
17
+ import ObjectID from "../../../Types/ObjectID";
18
+
19
+ /*
20
+ * A drop-in ModelAPI for tables that list TeamMember but mean to list *people*
21
+ * - the project's Users table being the one that does.
22
+ *
23
+ * TeamMember rows are (user, team) pairs, so the plain API pages over
24
+ * memberships: a user on three teams occupies three rows and reads as three
25
+ * different people with the same name. This API pages over users instead. It
26
+ * reads the project's memberships, folds them into one row per user (see
27
+ * TeamMembersByUser), and then applies the table's skip/limit to *that* list,
28
+ * so `count` is a number of people and every page boundary falls between two
29
+ * people rather than between two of one person's teams.
30
+ *
31
+ * Paging cannot be pushed to the server here: the list endpoint has no DISTINCT
32
+ * or GROUP BY, so there is no way to ask it for the Nth user. Reading the whole
33
+ * membership list up front is what the project user pickers already do.
34
+ *
35
+ * Deleting is redefined to match what a row now means - see deleteItem.
36
+ */
37
+ export default class ProjectUsersModelAPI extends ModelAPI {
38
+ public static override async getList<TBaseModel extends BaseModel>(data: {
39
+ modelType: { new (): TBaseModel };
40
+ query: Query<TBaseModel>;
41
+ groupBy?: GroupBy<TBaseModel> | undefined;
42
+ limit: number;
43
+ skip: number;
44
+ select: Select<TBaseModel>;
45
+ sort: Sort<TBaseModel>;
46
+ requestOptions?: RequestOptions | undefined;
47
+ }): Promise<ListResult<TBaseModel>> {
48
+ if (!(new data.modelType() instanceof TeamMember)) {
49
+ // Not the model this API exists for. Behave like the plain one.
50
+ return ModelAPI.getList<TBaseModel>(data);
51
+ }
52
+
53
+ const query: Query<TeamMember> = data.query as Query<TeamMember>;
54
+ const sort: Sort<TeamMember> = data.sort as Sort<TeamMember>;
55
+
56
+ const memberships: Array<TeamMember> = await this.readAllMemberships({
57
+ query: query,
58
+ select: this.getMembershipSelect(data.select as Select<TeamMember>),
59
+ sort: sort,
60
+ requestOptions: data.requestOptions,
61
+ });
62
+
63
+ let rows: Array<ProjectUserRow> =
64
+ TeamMembersByUser.groupByUser(memberships);
65
+
66
+ /*
67
+ * An explicit sort was already applied by the server and grouping kept it.
68
+ * Without one the server's order is arbitrary, and an arbitrary order that
69
+ * is then sliced into pages is worse here than elsewhere - so fall back to
70
+ * something a reader of a people directory expects, and that pages the same
71
+ * way every time.
72
+ */
73
+ if (Object.keys(sort || {}).length === 0) {
74
+ rows = TeamMembersByUser.sortRowsByUserName(rows);
75
+ }
76
+
77
+ const pageRows: Array<ProjectUserRow> = rows.slice(
78
+ data.skip,
79
+ data.skip + data.limit,
80
+ );
81
+
82
+ await this.fillInTeamsHiddenByFilters({
83
+ query: query,
84
+ pageRows: pageRows,
85
+ requestOptions: data.requestOptions,
86
+ });
87
+
88
+ return {
89
+ data: pageRows as unknown as Array<TBaseModel>,
90
+ count: rows.length,
91
+ skip: data.skip,
92
+ limit: data.limit,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Removes the user on this row from the project - every one of their team
98
+ * memberships, not just the one whose id the row happens to carry.
99
+ *
100
+ * A grouped row stands for a person, so deleting one membership would leave
101
+ * the row on screen (still a member, via their other teams) and look like the
102
+ * delete silently failed. Removing a user from a single team is done from
103
+ * that user's own Teams tab, where a row is a membership again.
104
+ *
105
+ * This goes through one `remove-user-from-project` request rather than a
106
+ * DELETE per membership, and that is load-bearing rather than an
107
+ * optimisation. The server refuses to remove the last accepted member of the
108
+ * Owners team, and it refuses per request - so a loop deletes every other
109
+ * membership first and only then reports a failure, leaving the person
110
+ * stripped of the teams the caller was just told they had not lost. The
111
+ * endpoint runs that check across the whole set before deleting anything.
112
+ */
113
+ public static override async deleteItem<TBaseModel extends BaseModel>(data: {
114
+ modelType: { new (): TBaseModel };
115
+ id: ObjectID;
116
+ requestOptions?: RequestOptions | undefined;
117
+ }): Promise<void> {
118
+ if (!(new data.modelType() instanceof TeamMember)) {
119
+ return ModelAPI.deleteItem<TBaseModel>(data);
120
+ }
121
+
122
+ const rowMembership: TeamMember | null = await ModelAPI.getItem<TeamMember>(
123
+ {
124
+ modelType: TeamMember,
125
+ id: data.id,
126
+ select: {
127
+ _id: true,
128
+ userId: true,
129
+ },
130
+ requestOptions: data.requestOptions,
131
+ },
132
+ );
133
+
134
+ const userId: ObjectID | undefined = rowMembership?.userId;
135
+
136
+ if (!userId) {
137
+ /*
138
+ * The row is gone, or came back without the user it stands for. Fall back
139
+ * to the single delete the table asked for rather than guessing.
140
+ */
141
+ return ModelAPI.deleteItem<TBaseModel>(data);
142
+ }
143
+
144
+ const url: URL = URL.fromString(APP_API_URL.toString())
145
+ .addRoute(new TeamMember().getCrudApiPath()!)
146
+ .addRoute("/remove-user-from-project");
147
+
148
+ const result: HTTPResponse<JSONObject> | HTTPErrorResponse =
149
+ await API.post<JSONObject>({
150
+ url: url,
151
+ data: {
152
+ userId: userId.toString(),
153
+ },
154
+ headers: ModelAPI.getCommonHeaders(data.requestOptions),
155
+ });
156
+
157
+ if (result instanceof HTTPErrorResponse) {
158
+ throw result;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Every membership matching the query, across as many requests as it takes.
164
+ *
165
+ * A single read would be capped at LIMIT_PER_PROJECT, and a capped read is
166
+ * indistinguishable here from a complete one: the grouped list would simply
167
+ * be missing whichever people fell outside the window, `count` would report
168
+ * the truncated total as authoritative, and nothing on screen would say so.
169
+ * Paging until a short page comes back keeps the grouping honest.
170
+ */
171
+ private static async readAllMemberships(data: {
172
+ query: Query<TeamMember>;
173
+ select: Select<TeamMember>;
174
+ sort: Sort<TeamMember>;
175
+ requestOptions?: RequestOptions | undefined;
176
+ }): Promise<Array<TeamMember>> {
177
+ const memberships: Array<TeamMember> = [];
178
+
179
+ /*
180
+ * A backstop, not an expected bound: every page after the first means a
181
+ * project with more than 10,000 memberships. Reaching it would mean
182
+ * something is paging without making progress.
183
+ */
184
+ const maxPages: number = 10;
185
+
186
+ for (let page: number = 0; page < maxPages; page++) {
187
+ const result: ListResult<TeamMember> = await ModelAPI.getList<TeamMember>(
188
+ {
189
+ modelType: TeamMember,
190
+ query: data.query,
191
+ limit: LIMIT_PER_PROJECT,
192
+ skip: page * LIMIT_PER_PROJECT,
193
+ select: data.select,
194
+ sort: data.sort,
195
+ requestOptions: data.requestOptions,
196
+ },
197
+ );
198
+
199
+ memberships.push(...result.data);
200
+
201
+ if (result.data.length < LIMIT_PER_PROJECT) {
202
+ // A short page is the last page.
203
+ break;
204
+ }
205
+ }
206
+
207
+ return memberships;
208
+ }
209
+
210
+ /**
211
+ * The table's own select plus the fields grouping needs. Columns ask for what
212
+ * they render; grouping also needs to know which user and which team each
213
+ * membership belongs to, and whether it was accepted.
214
+ */
215
+ private static getMembershipSelect(
216
+ select: Select<TeamMember>,
217
+ ): Select<TeamMember> {
218
+ const teamSelect: Record<string, boolean> =
219
+ typeof select?.team === "object" && select.team !== null
220
+ ? { ...(select.team as Record<string, boolean>) }
221
+ : {};
222
+
223
+ const userSelect: Record<string, boolean> =
224
+ typeof select?.user === "object" && select.user !== null
225
+ ? { ...(select.user as Record<string, boolean>) }
226
+ : {};
227
+
228
+ return {
229
+ ...select,
230
+ _id: true,
231
+ userId: true,
232
+ teamId: true,
233
+ hasAcceptedInvitation: true,
234
+ user: {
235
+ ...userSelect,
236
+ _id: true,
237
+ },
238
+ team: {
239
+ ...teamSelect,
240
+ _id: true,
241
+ name: true,
242
+ },
243
+ } as Select<TeamMember>;
244
+ }
245
+
246
+ /**
247
+ * When the table is filtered, the memberships that were read are only the
248
+ * matching ones - filter by a team and every user's Teams column would show
249
+ * that one team and nothing else, which is exactly the "which teams is this
250
+ * person on?" question the grouped row exists to answer.
251
+ *
252
+ * So for the users actually on this page (at most one page's worth), re-read
253
+ * their memberships unfiltered and merge the full team list back in.
254
+ */
255
+ private static async fillInTeamsHiddenByFilters(data: {
256
+ query: Query<TeamMember>;
257
+ pageRows: Array<ProjectUserRow>;
258
+ requestOptions?: RequestOptions | undefined;
259
+ }): Promise<void> {
260
+ const projectId: unknown = (data.query as Record<string, unknown>)[
261
+ "projectId"
262
+ ];
263
+
264
+ const isFiltered: boolean = Object.keys(data.query).some((key: string) => {
265
+ return key !== "projectId";
266
+ });
267
+
268
+ if (!isFiltered || !projectId || data.pageRows.length === 0) {
269
+ return;
270
+ }
271
+
272
+ const userIds: Array<string> = data.pageRows
273
+ .map((row: ProjectUserRow) => {
274
+ return TeamMembersByUser.getGroupKey(row) || "";
275
+ })
276
+ .filter((userId: string) => {
277
+ return Boolean(userId);
278
+ });
279
+
280
+ if (userIds.length === 0) {
281
+ return;
282
+ }
283
+
284
+ const allMemberships: Array<TeamMember> = await this.readAllMemberships({
285
+ query: {
286
+ projectId: projectId,
287
+ userId: new Includes(userIds),
288
+ } as Query<TeamMember>,
289
+ select: {
290
+ _id: true,
291
+ userId: true,
292
+ teamId: true,
293
+ hasAcceptedInvitation: true,
294
+ team: {
295
+ _id: true,
296
+ name: true,
297
+ },
298
+ } as Select<TeamMember>,
299
+ sort: {},
300
+ requestOptions: data.requestOptions,
301
+ });
302
+
303
+ TeamMembersByUser.mergeAllTeamsIntoRows(data.pageRows, allMemberships);
304
+ }
305
+ }