@oneuptime/common 12.0.0 → 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 (237) 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/OnCallDutyPolicyScheduleLayer.ts +12 -2
  5. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  6. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  7. package/Models/DatabaseModels/StatusPage.ts +95 -0
  8. package/Server/API/TeamMemberAPI.ts +85 -0
  9. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  15. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  16. package/Server/Services/BillingService.ts +600 -51
  17. package/Server/Services/CephClusterService.ts +47 -5
  18. package/Server/Services/CephResourceService.ts +43 -4
  19. package/Server/Services/CloudResourceService.ts +47 -5
  20. package/Server/Services/DatabaseService.ts +87 -6
  21. package/Server/Services/DockerHostService.ts +47 -5
  22. package/Server/Services/DockerResourceService.ts +62 -8
  23. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  24. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  25. package/Server/Services/HostService.ts +48 -5
  26. package/Server/Services/IoTDeviceService.ts +1 -17
  27. package/Server/Services/IoTFleetService.ts +47 -5
  28. package/Server/Services/KubernetesClusterService.ts +48 -5
  29. package/Server/Services/KubernetesContainerService.ts +50 -9
  30. package/Server/Services/KubernetesResourceService.ts +53 -7
  31. package/Server/Services/LabelService.ts +106 -1
  32. package/Server/Services/LlmProviderService.ts +69 -9
  33. package/Server/Services/MonitorService.ts +57 -0
  34. package/Server/Services/PodmanHostService.ts +47 -5
  35. package/Server/Services/PodmanResourceService.ts +62 -8
  36. package/Server/Services/ProjectService.ts +165 -35
  37. package/Server/Services/ProxmoxClusterService.ts +47 -5
  38. package/Server/Services/ProxmoxResourceService.ts +43 -4
  39. package/Server/Services/RumApplicationService.ts +48 -5
  40. package/Server/Services/RunnerService.ts +1 -7
  41. package/Server/Services/ServerlessFunctionService.ts +48 -5
  42. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  43. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  44. package/Server/Services/StatusPageService.ts +95 -36
  45. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  46. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  47. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  48. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  49. package/Server/Types/Database/QueryHelper.ts +17 -32
  50. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  51. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  52. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  53. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  54. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  55. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  56. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  57. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  58. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  59. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  60. package/Tests/Server/Services/BillingService.test.ts +78 -3
  61. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  62. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  63. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  64. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  65. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  66. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  67. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  68. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  69. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  70. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  71. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  72. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  73. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  74. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  75. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  76. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  77. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  78. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  79. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  80. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  81. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  82. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  83. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  84. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  85. package/Tests/Types/Events/Recurring.test.ts +157 -0
  86. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  87. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  88. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  89. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  90. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  91. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  92. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  93. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  94. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  95. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  96. package/Types/Date.ts +112 -0
  97. package/Types/Events/Recurring.ts +99 -1
  98. package/Types/Runbook/RunbookStep.ts +15 -0
  99. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  100. package/Types/StatusPage/StatusPageReport.ts +11 -0
  101. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  102. package/UI/Components/Detail/Detail.tsx +155 -0
  103. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  104. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  105. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  106. package/UI/Utils/TeamMembersByUser.ts +302 -0
  107. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  108. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  109. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  110. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  111. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  112. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  114. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  116. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  118. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  120. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  122. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  123. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  124. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  126. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  137. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  138. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  139. package/build/dist/Server/Services/BillingService.js +466 -36
  140. package/build/dist/Server/Services/BillingService.js.map +1 -1
  141. package/build/dist/Server/Services/CephClusterService.js +42 -5
  142. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  143. package/build/dist/Server/Services/CephResourceService.js +28 -3
  144. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  145. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  146. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  147. package/build/dist/Server/Services/DatabaseService.js +69 -2
  148. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  149. package/build/dist/Server/Services/DockerHostService.js +42 -5
  150. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  151. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  152. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  153. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  154. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  155. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  156. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  157. package/build/dist/Server/Services/HostService.js +43 -5
  158. package/build/dist/Server/Services/HostService.js.map +1 -1
  159. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  160. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  161. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  162. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  163. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  164. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  165. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  166. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  167. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  168. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  169. package/build/dist/Server/Services/LabelService.js +92 -0
  170. package/build/dist/Server/Services/LabelService.js.map +1 -1
  171. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  172. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  173. package/build/dist/Server/Services/MonitorService.js +47 -0
  174. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  175. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  176. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  177. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  178. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  179. package/build/dist/Server/Services/ProjectService.js +135 -21
  180. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  181. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  182. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  183. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  184. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  185. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  186. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  187. package/build/dist/Server/Services/RunnerService.js +1 -6
  188. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  189. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  190. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  191. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  192. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  193. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  194. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +69 -46
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  198. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  199. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  200. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  201. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  202. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  203. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  204. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  205. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  206. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  208. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  210. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  211. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  212. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  213. package/build/dist/Types/Date.js +68 -0
  214. package/build/dist/Types/Date.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +63 -0
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  218. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  219. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  220. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  221. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  222. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  223. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  224. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  226. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  227. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  228. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  229. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  230. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  231. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  232. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  233. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  234. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  235. package/jest.config.json +2 -0
  236. package/package.json +1 -1
  237. package/tsconfig.json +12 -1
@@ -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
+ }
@@ -0,0 +1,302 @@
1
+ import Team from "../../Models/DatabaseModels/Team";
2
+ import TeamMember from "../../Models/DatabaseModels/TeamMember";
3
+
4
+ /*
5
+ * TeamMember is a (user, team) pair, so a list of team members is a list of
6
+ * memberships - not a list of people. Rendering it straight into a "Users"
7
+ * table gives one row per membership, and someone who belongs to three teams
8
+ * reads as three separate people with the same name and avatar.
9
+ *
10
+ * These helpers collapse memberships into one row per person, carrying every
11
+ * team that person belongs to on the row so the table can show them together.
12
+ * The row is still a TeamMember (the table, its columns and its delete action
13
+ * are all typed against the model) - it just stands for the user rather than
14
+ * for one of their memberships.
15
+ */
16
+
17
+ export interface ProjectUserExtras {
18
+ // Every team this user belongs to, sorted by name.
19
+ teamsForUser: Array<Team>;
20
+ teamCountForUser: number;
21
+ /*
22
+ * How many of those memberships are still unaccepted invitations. A user can
23
+ * be a fully accepted member of one team and a pending invite on another.
24
+ */
25
+ pendingTeamCountForUser: number;
26
+ }
27
+
28
+ export type ProjectUserRow = TeamMember & ProjectUserExtras;
29
+
30
+ export default class TeamMembersByUser {
31
+ /**
32
+ * The identity a membership is grouped under. Null when the row carries no
33
+ * user at all - those rows are never merged, because merging them would put
34
+ * unrelated memberships on one line.
35
+ */
36
+ public static getGroupKey(teamMember: TeamMember): string | null {
37
+ const userId: string =
38
+ teamMember.userId?.toString() || teamMember.user?._id?.toString() || "";
39
+
40
+ return userId || null;
41
+ }
42
+
43
+ /**
44
+ * The name this row sorts and reads under. Falls back to the email so a user
45
+ * who was invited but has not set a name yet still lands somewhere sensible.
46
+ */
47
+ public static getDisplayName(teamMember: TeamMember): string {
48
+ return (
49
+ teamMember.user?.name?.toString() ||
50
+ teamMember.user?.email?.toString() ||
51
+ ""
52
+ );
53
+ }
54
+
55
+ /**
56
+ * One row per user, in the order each user was first seen. The input order is
57
+ * whatever the server sorted by, so first-seen order keeps that sort intact.
58
+ *
59
+ * The returned rows are the same TeamMember instances that came in (the first
60
+ * membership seen for each user), mutated with the aggregate fields.
61
+ */
62
+ public static groupByUser(
63
+ teamMembers: Array<TeamMember>,
64
+ ): Array<ProjectUserRow> {
65
+ const rowsByGroupKey: Map<string, ProjectUserRow> = new Map<
66
+ string,
67
+ ProjectUserRow
68
+ >();
69
+ const rows: Array<ProjectUserRow> = [];
70
+
71
+ /*
72
+ * Which teams are already on a row, keyed the same way for every
73
+ * membership. Tracked here rather than re-derived from `teamsForUser`
74
+ * because a membership identifies its team by `teamId` while the team
75
+ * object it carries identifies itself by `_id`, and either one can be
76
+ * missing from a given select.
77
+ */
78
+ const teamKeysByRow: Map<ProjectUserRow, Set<string>> = new Map<
79
+ ProjectUserRow,
80
+ Set<string>
81
+ >();
82
+
83
+ /*
84
+ * Every membership id folded into each row, so the row can be given a
85
+ * stable identity below.
86
+ */
87
+ const membershipIdsByRow: Map<ProjectUserRow, Array<string>> = new Map<
88
+ ProjectUserRow,
89
+ Array<string>
90
+ >();
91
+
92
+ for (const teamMember of teamMembers) {
93
+ const groupKey: string | null = this.getGroupKey(teamMember);
94
+
95
+ const existingRow: ProjectUserRow | undefined = groupKey
96
+ ? rowsByGroupKey.get(groupKey)
97
+ : undefined;
98
+
99
+ if (existingRow) {
100
+ this.addMembershipToRow(
101
+ existingRow,
102
+ teamMember,
103
+ teamKeysByRow.get(existingRow)!,
104
+ Boolean(teamMember.hasAcceptedInvitation),
105
+ );
106
+ this.rememberMembershipId(
107
+ membershipIdsByRow.get(existingRow)!,
108
+ teamMember,
109
+ );
110
+ continue;
111
+ }
112
+
113
+ const row: ProjectUserRow = teamMember as ProjectUserRow;
114
+ const teamKeys: Set<string> = new Set<string>();
115
+ const membershipIds: Array<string> = [];
116
+
117
+ /*
118
+ * The row *is* this membership, so read its acceptance before the reset
119
+ * below overwrites it.
120
+ */
121
+ const hasAcceptedInvitation: boolean = Boolean(
122
+ teamMember.hasAcceptedInvitation,
123
+ );
124
+
125
+ row.teamsForUser = [];
126
+ row.teamCountForUser = 0;
127
+ row.pendingTeamCountForUser = 0;
128
+
129
+ /*
130
+ * Reset before folding the row's own membership back in: on a grouped row
131
+ * this field means "is a member of this project", which is true as soon as
132
+ * any one of the memberships has been accepted.
133
+ */
134
+ row.hasAcceptedInvitation = false;
135
+
136
+ teamKeysByRow.set(row, teamKeys);
137
+ membershipIdsByRow.set(row, membershipIds);
138
+ this.addMembershipToRow(row, teamMember, teamKeys, hasAcceptedInvitation);
139
+ this.rememberMembershipId(membershipIds, teamMember);
140
+
141
+ if (groupKey) {
142
+ rowsByGroupKey.set(groupKey, row);
143
+ }
144
+
145
+ rows.push(row);
146
+ }
147
+
148
+ for (const row of rows) {
149
+ this.finalizeRow(row, membershipIdsByRow.get(row) || []);
150
+ }
151
+
152
+ return rows;
153
+ }
154
+
155
+ /**
156
+ * Replaces the team aggregate on each row with the one derived from
157
+ * `allTeamMembers`.
158
+ *
159
+ * Needed because the rows on screen may have been built from a *filtered*
160
+ * membership list - filter the table by one team and grouping only ever sees
161
+ * that team, so the Teams column would claim each user belongs to exactly the
162
+ * team that was filtered for. The caller re-reads the unfiltered memberships
163
+ * for the users on the current page and merges them back in here.
164
+ *
165
+ * Rows with no matching membership in `allTeamMembers` are left untouched.
166
+ */
167
+ public static mergeAllTeamsIntoRows(
168
+ rows: Array<ProjectUserRow>,
169
+ allTeamMembers: Array<TeamMember>,
170
+ ): Array<ProjectUserRow> {
171
+ const groupedRows: Array<ProjectUserRow> = this.groupByUser(allTeamMembers);
172
+
173
+ const groupedRowsByKey: Map<string, ProjectUserRow> = new Map<
174
+ string,
175
+ ProjectUserRow
176
+ >();
177
+
178
+ for (const groupedRow of groupedRows) {
179
+ const groupKey: string | null = this.getGroupKey(groupedRow);
180
+
181
+ if (groupKey) {
182
+ groupedRowsByKey.set(groupKey, groupedRow);
183
+ }
184
+ }
185
+
186
+ for (const row of rows) {
187
+ const groupKey: string | null = this.getGroupKey(row);
188
+ const groupedRow: ProjectUserRow | undefined = groupKey
189
+ ? groupedRowsByKey.get(groupKey)
190
+ : undefined;
191
+
192
+ if (!groupedRow) {
193
+ continue;
194
+ }
195
+
196
+ row.teamsForUser = groupedRow.teamsForUser;
197
+ row.teamCountForUser = groupedRow.teamCountForUser;
198
+ row.pendingTeamCountForUser = groupedRow.pendingTeamCountForUser;
199
+ row.hasAcceptedInvitation = Boolean(groupedRow.hasAcceptedInvitation);
200
+ }
201
+
202
+ return rows;
203
+ }
204
+
205
+ /**
206
+ * Alphabetical by display name, case-insensitively. Rows with no name at all
207
+ * sort last so the list does not open on a run of blanks.
208
+ *
209
+ * Returns a new array; the input is left alone.
210
+ */
211
+ public static sortRowsByUserName(
212
+ rows: Array<ProjectUserRow>,
213
+ ): Array<ProjectUserRow> {
214
+ return [...rows].sort((a: ProjectUserRow, b: ProjectUserRow) => {
215
+ const nameA: string = this.getDisplayName(a).toLowerCase();
216
+ const nameB: string = this.getDisplayName(b).toLowerCase();
217
+
218
+ if (!nameA && !nameB) {
219
+ return 0;
220
+ }
221
+
222
+ if (!nameA) {
223
+ return 1;
224
+ }
225
+
226
+ if (!nameB) {
227
+ return -1;
228
+ }
229
+
230
+ return nameA.localeCompare(nameB);
231
+ });
232
+ }
233
+
234
+ private static addMembershipToRow(
235
+ row: ProjectUserRow,
236
+ teamMember: TeamMember,
237
+ teamKeys: Set<string>,
238
+ hasAcceptedInvitation: boolean,
239
+ ): void {
240
+ if (hasAcceptedInvitation) {
241
+ row.hasAcceptedInvitation = true;
242
+ } else {
243
+ row.pendingTeamCountForUser = row.pendingTeamCountForUser + 1;
244
+ }
245
+
246
+ const team: Team | undefined = teamMember.team;
247
+
248
+ if (!team) {
249
+ return;
250
+ }
251
+
252
+ const teamKey: string =
253
+ teamMember.teamId?.toString() ||
254
+ team._id?.toString() ||
255
+ team.name?.toString() ||
256
+ "";
257
+
258
+ if (!teamKey || teamKeys.has(teamKey)) {
259
+ return;
260
+ }
261
+
262
+ teamKeys.add(teamKey);
263
+ row.teamsForUser.push(team);
264
+ }
265
+
266
+ private static rememberMembershipId(
267
+ membershipIds: Array<string>,
268
+ teamMember: TeamMember,
269
+ ): void {
270
+ const membershipId: string = teamMember._id?.toString() || "";
271
+
272
+ if (membershipId) {
273
+ membershipIds.push(membershipId);
274
+ }
275
+ }
276
+
277
+ private static finalizeRow(
278
+ row: ProjectUserRow,
279
+ membershipIds: Array<string>,
280
+ ): void {
281
+ row.teamsForUser.sort((a: Team, b: Team) => {
282
+ return (a.name?.toString() || "")
283
+ .toLowerCase()
284
+ .localeCompare((b.name?.toString() || "").toLowerCase());
285
+ });
286
+
287
+ row.teamCountForUser = row.teamsForUser.length;
288
+
289
+ /*
290
+ * The row's id has to identify the *person*, not "whichever of their
291
+ * memberships was returned first". The table matches a selected row to an
292
+ * on-screen row by _id, and it fetches the on-screen page and the
293
+ * select-all set with two different sorts - so a first-seen id makes the
294
+ * same person carry different ids in the two results, and every
295
+ * multi-team user's checkbox silently fails to tick. The lowest membership
296
+ * id is stable under any ordering the server returns.
297
+ */
298
+ if (membershipIds.length > 1) {
299
+ row._id = [...membershipIds].sort()[0]!;
300
+ }
301
+ }
302
+ }