@tellescope/sdk 1.256.11 → 1.256.13

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 (46) hide show
  1. package/lib/cjs/sdk.d.ts +1 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +1 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/active_patient_count.test.d.ts +15 -0
  6. package/lib/cjs/tests/api_tests/active_patient_count.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/active_patient_count.test.js +594 -0
  8. package/lib/cjs/tests/api_tests/active_patient_count.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/out_of_office_endpoint.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/out_of_office_endpoint.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/out_of_office_endpoint.test.js +192 -0
  12. package/lib/cjs/tests/api_tests/out_of_office_endpoint.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +5 -0
  14. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -1
  15. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +95 -31
  16. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -1
  17. package/lib/cjs/tests/tests.d.ts.map +1 -1
  18. package/lib/cjs/tests/tests.js +156 -148
  19. package/lib/cjs/tests/tests.js.map +1 -1
  20. package/lib/esm/sdk.d.ts +1 -0
  21. package/lib/esm/sdk.d.ts.map +1 -1
  22. package/lib/esm/sdk.js +1 -0
  23. package/lib/esm/sdk.js.map +1 -1
  24. package/lib/esm/tests/api_tests/active_patient_count.test.d.ts +15 -0
  25. package/lib/esm/tests/api_tests/active_patient_count.test.d.ts.map +1 -0
  26. package/lib/esm/tests/api_tests/active_patient_count.test.js +590 -0
  27. package/lib/esm/tests/api_tests/active_patient_count.test.js.map +1 -0
  28. package/lib/esm/tests/api_tests/out_of_office_endpoint.test.d.ts +6 -0
  29. package/lib/esm/tests/api_tests/out_of_office_endpoint.test.d.ts.map +1 -0
  30. package/lib/esm/tests/api_tests/out_of_office_endpoint.test.js +188 -0
  31. package/lib/esm/tests/api_tests/out_of_office_endpoint.test.js.map +1 -0
  32. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +5 -0
  33. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -1
  34. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +95 -31
  35. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -1
  36. package/lib/esm/tests/tests.d.ts.map +1 -1
  37. package/lib/esm/tests/tests.js +156 -148
  38. package/lib/esm/tests/tests.js.map +1 -1
  39. package/lib/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +10 -10
  41. package/src/sdk.ts +4 -0
  42. package/src/tests/api_tests/active_patient_count.test.ts +435 -0
  43. package/src/tests/api_tests/out_of_office_endpoint.test.ts +162 -0
  44. package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +56 -0
  45. package/src/tests/tests.ts +4 -0
  46. package/test_generated.pdf +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tellescope/sdk",
3
- "version": "1.256.11",
3
+ "version": "1.256.13",
4
4
  "description": "Code for interacting with the Tellescope API",
5
5
  "main": "./lib/cjs/sdk.js",
6
6
  "module": "./lib/esm/sdk.js",
@@ -32,14 +32,14 @@
32
32
  },
33
33
  "homepage": "https://github.com/tellescope-os/tellescope#readme",
34
34
  "dependencies": {
35
- "@tellescope/constants": "1.256.11",
36
- "@tellescope/schema": "1.256.11",
37
- "@tellescope/testing": "1.256.11",
38
- "@tellescope/types-client": "1.256.11",
39
- "@tellescope/types-models": "1.256.11",
40
- "@tellescope/types-utilities": "1.256.11",
41
- "@tellescope/utilities": "1.256.11",
42
- "@tellescope/validation": "1.256.11",
35
+ "@tellescope/constants": "1.256.13",
36
+ "@tellescope/schema": "1.256.13",
37
+ "@tellescope/testing": "1.256.13",
38
+ "@tellescope/types-client": "1.256.13",
39
+ "@tellescope/types-models": "1.256.13",
40
+ "@tellescope/types-utilities": "1.256.13",
41
+ "@tellescope/utilities": "1.256.13",
42
+ "@tellescope/validation": "1.256.13",
43
43
  "axios": "0.30.3",
44
44
  "dotenv": "14.3.2",
45
45
  "form-data": "4.0.4",
@@ -55,5 +55,5 @@
55
55
  "publishConfig": {
56
56
  "access": "public"
57
57
  },
58
- "gitHead": "e0b2785223de7100a93651f4cf6f501223103054"
58
+ "gitHead": "457e037174ead2b05f585d810de05f724659430a"
59
59
  }
package/src/sdk.ts CHANGED
@@ -810,6 +810,9 @@ type Queries = { [K in keyof ClientModelForName]: APIQuery<K> } & {
810
810
  load_events: (args: extractFields<CustomActions['calendar_events']['load_events']['parameters']>) => (
811
811
  Promise<extractFields<CustomActions['calendar_events']['load_events']['returns']>>
812
812
  ),
813
+ out_of_office: (args?: extractFields<CustomActions['calendar_events']['out_of_office']['parameters']>) => (
814
+ Promise<extractFields<CustomActions['calendar_events']['out_of_office']['returns']>>
815
+ ),
813
816
  generate_zoom_meeting: (args: extractFields<CustomActions['calendar_events']['generate_zoom_meeting']['parameters']>) => (
814
817
  Promise<extractFields<CustomActions['calendar_events']['generate_zoom_meeting']['returns']>>
815
818
  ),
@@ -1146,6 +1149,7 @@ export class Session extends SessionManager {
1146
1149
  queries.calendar_events.session_for_start_link = a => this._GET(`/v1${schema.calendar_events.publicActions.session_for_start_link.path}`, a)
1147
1150
  queries.calendar_events.get_events_for_user = a => this._GET(`/v1/${schema.calendar_events.customActions.get_events_for_user.path}`, a)
1148
1151
  queries.calendar_events.load_events = a => this._GET(`/v1/${schema.calendar_events.customActions.load_events.path}`, a)
1152
+ queries.calendar_events.out_of_office = a => this._GET(`/v1${schema.calendar_events.customActions.out_of_office.path}`, a)
1149
1153
  queries.calendar_events.generate_meeting_link = a => this._POST(`/v1/${schema.calendar_events.customActions.generate_meeting_link.path}`, a)
1150
1154
  queries.calendar_events.generate_zoom_meeting = a => this._POST(`/v1/${schema.calendar_events.customActions.generate_zoom_meeting.path}`, a)
1151
1155
  queries.calendar_events.change_zoom_host = a => this._POST(`/v1/${schema.calendar_events.customActions.change_zoom_host.path}`, a)
@@ -0,0 +1,435 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session, EnduserSession } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ handleAnyError,
7
+ log_header,
8
+ wait,
9
+ } from "@tellescope/testing"
10
+ import { authenticate_enduser_via_token, setup_tests } from "../setup"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+ const businessId = '60398b1131a295e64f084ff6'
14
+
15
+ // Second-tenant session, used to prove the active patient count never crosses tenants.
16
+ // Same key used by the org-isolation tests in tests.ts.
17
+ const OTHER_TENANT_API_KEY = "ba745e25162bb95a795c5fa1af70df188d93c4d3aac9c48b34a5c8c9dd7b80f7"
18
+
19
+ // Arbitrary valid ObjectId — endusers carrying a customTypeId are excluded from the default count
20
+ const TEST_CUSTOM_TYPE_ID = '60398b0231a295e64f084fd9'
21
+
22
+ /**
23
+ * Locks in the behavior of getEngagedEndusers (endusers/engagement), which backs both the billing
24
+ * /usage endpoint and the super-admin active patient count. Every case creates exactly one
25
+ * qualifying (or deliberately non-qualifying) engagement record and asserts its effect on the
26
+ * distinct enduser count.
27
+ *
28
+ * All assertions are made inside a time window opened at the top of the test, so unrelated records
29
+ * left behind by other suites can't move the numbers.
30
+ */
31
+ export const active_patient_count_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
32
+ log_header("Active Patient Count Tests")
33
+
34
+ const userId = sdk.userInfo.id
35
+
36
+ // _id-based range filters use ObjectId.createFromTime, which floors to whole seconds — back off a
37
+ // couple of seconds so nothing created below can land before the window starts.
38
+ const windowStart = new Date(Date.now() - 2000)
39
+
40
+ const activeCount = (args: Parameters<typeof sdk.api.endusers.get_engagement_statistics>[0] = {}) => (
41
+ sdk.api.endusers.get_engagement_statistics({ range: { from: windowStart }, ...args })
42
+ .then(r => r.count)
43
+ )
44
+
45
+ // Scenarios are identified by fields.apcCase rather than fname, because fname is title-cased on
46
+ // create ('apc-email' is stored as 'Apc-email') while stored fields are kept verbatim.
47
+ const onlyCase = (apcCase: string) => activeCount({ enduserFields: [{ field: 'fields.apcCase', value: apcCase }] })
48
+ // every enduser this test creates, and nothing else
49
+ const onlyThisTest = (args: Parameters<typeof activeCount>[0] = {}) => activeCount({
50
+ ...args, enduserFields: [{ field: 'fields.apcCase', value: '$exists' }],
51
+ })
52
+
53
+ // One enduser per scenario, created in a single call to avoid create throttling
54
+ const [
55
+ eEmail, eEmailOutbound,
56
+ eSMS, eSMSOutbound,
57
+ eCallInbound, eCallLongOutbound, eCallShortOutbound,
58
+ eChat, eChatNoEnduser,
59
+ eForm, eFormUnsubmitted, eFormOtherForm,
60
+ eCalendar, eCalendarUserOnly, eCalendarPastStart,
61
+ eGroupMMS, eGroupMMSUserSender,
62
+ eDedupe,
63
+ eCustomType,
64
+ eInactive,
65
+ ] = (await sdk.api.endusers.createSome([
66
+ { fname: 'apc-email', fields: { apcCase: 'email', apcTag: 'yes', apcGroup: 'A' } },
67
+ { fname: 'apc-email-outbound', fields: { apcCase: 'email-outbound' } },
68
+ { fname: 'apc-sms', fields: { apcCase: 'sms', apcTag: 'yes', apcGroup: 'A' } },
69
+ { fname: 'apc-sms-outbound', fields: { apcCase: 'sms-outbound' } },
70
+ { fname: 'apc-call-inbound', fields: { apcCase: 'call-inbound', apcGroup: 'B' } },
71
+ { fname: 'apc-call-long-outbound', fields: { apcCase: 'call-long-outbound', apcGroup: 'B' } },
72
+ { fname: 'apc-call-short-outbound', fields: { apcCase: 'call-short-outbound' } },
73
+ { fname: 'apc-chat', fields: { apcCase: 'chat', apcGroup: 'B' } },
74
+ { fname: 'apc-chat-no-enduserid', fields: { apcCase: 'chat-no-enduserid' } },
75
+ { fname: 'apc-form', fields: { apcCase: 'form', apcGroup: 'C' } },
76
+ { fname: 'apc-form-unsubmitted', fields: { apcCase: 'form-unsubmitted' } },
77
+ { fname: 'apc-form-other-form', fields: { apcCase: 'form-other-form', apcGroup: 'C' } },
78
+ // deliberately has no apcGroup, so the $exists case below is narrower than the total
79
+ { fname: 'apc-calendar', fields: { apcCase: 'calendar' } },
80
+ { fname: 'apc-calendar-user-only', fields: { apcCase: 'calendar-user-only' } },
81
+ { fname: 'apc-calendar-past-start', fields: { apcCase: 'calendar-past-start' } },
82
+ { fname: 'apc-group-mms', fields: { apcCase: 'group-mms', apcGroup: 'D' } },
83
+ { fname: 'apc-group-mms-user-sender', fields: { apcCase: 'group-mms-user-sender' } },
84
+ { fname: 'apc-dedupe', fields: { apcCase: 'dedupe', apcTag: 'yes', apcGroup: 'D' } },
85
+ { fname: 'apc-custom-type', customTypeId: TEST_CUSTOM_TYPE_ID, fields: { apcCase: 'custom-type' } },
86
+ { fname: 'apc-inactive', fields: { apcCase: 'inactive' } },
87
+ ])).created
88
+
89
+ const createdEnduserIds = [
90
+ eEmail, eEmailOutbound, eSMS, eSMSOutbound, eCallInbound, eCallLongOutbound, eCallShortOutbound,
91
+ eChat, eChatNoEnduser, eForm, eFormUnsubmitted, eFormOtherForm, eCalendar, eCalendarUserOnly,
92
+ eCalendarPastStart, eGroupMMS, eGroupMMSUserSender, eDedupe, eCustomType, eInactive,
93
+ ].map(e => e.id)
94
+
95
+ const chatRoomIds: string[] = []
96
+ const conversationIds: string[] = []
97
+ const calendarEventIds: string[] = []
98
+ const formIds: string[] = []
99
+
100
+ try {
101
+ // --- emails ---------------------------------------------------------------------------------
102
+ await sdk.api.emails.createOne({
103
+ logOnly: true, inbound: true, userId,
104
+ enduserId: eEmail.id, subject: 'apc inbound', textContent: 'inbound email',
105
+ })
106
+ // outbound with no link opens does not qualify
107
+ await sdk.api.emails.createOne({
108
+ logOnly: true, inbound: false, userId,
109
+ enduserId: eEmailOutbound.id, subject: 'apc outbound', textContent: 'outbound email',
110
+ })
111
+
112
+ // --- sms_messages ---------------------------------------------------------------------------
113
+ await sdk.api.sms_messages.createOne({
114
+ logOnly: true, inbound: true, userId,
115
+ enduserId: eSMS.id, message: 'apc inbound sms',
116
+ })
117
+ await sdk.api.sms_messages.createOne({
118
+ logOnly: true, inbound: false, userId,
119
+ enduserId: eSMSOutbound.id, message: 'apc outbound sms',
120
+ })
121
+
122
+ // --- phone_calls ----------------------------------------------------------------------------
123
+ // inbound qualifies regardless of duration
124
+ await sdk.api.phone_calls.createOne({ enduserId: eCallInbound.id, inbound: true, userId })
125
+ // outbound qualifies only at >= 60 seconds
126
+ await sdk.api.phone_calls.createOne({
127
+ enduserId: eCallLongOutbound.id, inbound: false, userId, callDurationInSeconds: 60,
128
+ })
129
+ await sdk.api.phone_calls.createOne({
130
+ enduserId: eCallShortOutbound.id, inbound: false, userId, callDurationInSeconds: 59,
131
+ })
132
+
133
+ // --- chats ----------------------------------------------------------------------------------
134
+ const chatRoom = await sdk.api.chat_rooms.createOne({ enduserIds: [eChat.id], userIds: [userId] })
135
+ chatRoomIds.push(chatRoom.id)
136
+ await sdk.api.chats.createOne({
137
+ roomId: chatRoom.id, message: 'apc chat', senderId: eChat.id, enduserId: eChat.id,
138
+ })
139
+
140
+ // a chat with no enduserId can't be attributed to a patient
141
+ const chatRoomNoEnduser = await sdk.api.chat_rooms.createOne({
142
+ enduserIds: [eChatNoEnduser.id], userIds: [userId],
143
+ })
144
+ chatRoomIds.push(chatRoomNoEnduser.id)
145
+ await sdk.api.chats.createOne({
146
+ roomId: chatRoomNoEnduser.id, message: 'apc chat without enduserId', senderId: userId,
147
+ })
148
+
149
+ // --- form_responses -------------------------------------------------------------------------
150
+ const formA = await sdk.api.forms.createOne({ title: 'Active Patient Count Form A' })
151
+ formIds.push(formA.id)
152
+ const fieldA = await sdk.api.form_fields.createOne({
153
+ formId: formA.id, type: 'string', title: 'FieldA', previousFields: [{ type: 'root', info: {} }],
154
+ })
155
+
156
+ const formB = await sdk.api.forms.createOne({ title: 'Active Patient Count Form B' })
157
+ formIds.push(formB.id)
158
+ const fieldB = await sdk.api.form_fields.createOne({
159
+ formId: formB.id, type: 'string', title: 'FieldB', previousFields: [{ type: 'root', info: {} }],
160
+ })
161
+
162
+ const submitForm = async (formId: string, fieldId: string, fieldTitle: string, enduserId: string) => {
163
+ const { accessCode } = await sdk.api.form_responses.prepare_form_response({ enduserId, formId })
164
+ await sdk.api.form_responses.submit_form_response({
165
+ accessCode,
166
+ responses: [{ fieldId, fieldTitle, answer: { type: 'string', value: 'apc' } }],
167
+ })
168
+ }
169
+
170
+ await submitForm(formA.id, fieldA.id, 'FieldA', eForm.id)
171
+ await submitForm(formB.id, fieldB.id, 'FieldB', eFormOtherForm.id)
172
+
173
+ // prepared but never submitted — no submittedAt, so it must not qualify
174
+ await sdk.api.form_responses.prepare_form_response({
175
+ enduserId: eFormUnsubmitted.id, formId: formA.id,
176
+ })
177
+
178
+ // --- calendar_events ------------------------------------------------------------------------
179
+ // starts now, not in the future — the bounded-range case below closes the window a couple of
180
+ // seconds out, and calendar_events are filtered on startTimeInMS
181
+ const calendarEvent = await sdk.api.calendar_events.createOne({
182
+ title: 'apc event', durationInMinutes: 30, startTimeInMS: Date.now(),
183
+ attendees: [{ id: eCalendar.id, type: 'enduser' }],
184
+ })
185
+ calendarEventIds.push(calendarEvent.id)
186
+
187
+ const calendarEventUserOnly = await sdk.api.calendar_events.createOne({
188
+ title: 'apc event user only', durationInMinutes: 30, startTimeInMS: Date.now(),
189
+ attendees: [{ id: userId, type: 'user' }],
190
+ })
191
+ calendarEventIds.push(calendarEventUserOnly.id)
192
+
193
+ // created now, but starts 30 days ago — calendar_events filter on startTimeInMS, not _id, so
194
+ // this must fall outside the window even though the record itself is brand new
195
+ const calendarEventPastStart = await sdk.api.calendar_events.createOne({
196
+ title: 'apc event past start', durationInMinutes: 30,
197
+ startTimeInMS: Date.now() - 30 * 24 * 60 * 60 * 1000,
198
+ attendees: [{ id: eCalendarPastStart.id, type: 'enduser' }],
199
+ })
200
+ calendarEventIds.push(calendarEventPastStart.id)
201
+
202
+ // --- group_mms_conversations ----------------------------------------------------------------
203
+ const conversation = await sdk.api.group_mms_conversations.createOne({
204
+ enduserIds: [eGroupMMS.id, eGroupMMSUserSender.id], userIds: [userId], userStates: [], messages: [],
205
+ })
206
+ conversationIds.push(conversation.id)
207
+
208
+ await sdk.api.group_mms_conversations.send_message({
209
+ conversationId: conversation.id, sender: eGroupMMS.id, message: 'apc group mms', logOnly: true,
210
+ })
211
+ await wait(undefined, 250)
212
+ // sent by a user, so it counts for nobody — the other enduser in the conversation included
213
+ await sdk.api.group_mms_conversations.send_message({
214
+ conversationId: conversation.id, sender: userId, message: 'apc group mms from user', logOnly: true,
215
+ })
216
+ await wait(undefined, 250)
217
+
218
+ // --- dedupe: one enduser active across three channels ----------------------------------------
219
+ await sdk.api.emails.createOne({
220
+ logOnly: true, inbound: true, userId,
221
+ enduserId: eDedupe.id, subject: 'apc dedupe', textContent: 'inbound email',
222
+ })
223
+ await sdk.api.sms_messages.createOne({
224
+ logOnly: true, inbound: true, userId, enduserId: eDedupe.id, message: 'apc dedupe sms',
225
+ })
226
+ const dedupeRoom = await sdk.api.chat_rooms.createOne({ enduserIds: [eDedupe.id], userIds: [userId] })
227
+ chatRoomIds.push(dedupeRoom.id)
228
+ await sdk.api.chats.createOne({
229
+ roomId: dedupeRoom.id, message: 'apc dedupe chat', senderId: eDedupe.id, enduserId: eDedupe.id,
230
+ })
231
+
232
+ // --- customTypeId: excluded from the default count --------------------------------------------
233
+ await sdk.api.emails.createOne({
234
+ logOnly: true, inbound: true, userId,
235
+ enduserId: eCustomType.id, subject: 'apc custom type', textContent: 'inbound email',
236
+ })
237
+
238
+ await wait(undefined, 1000) // let side effects settle
239
+
240
+ // Qualifying endusers: email, sms, inbound call, long outbound call, chat, form A, form B,
241
+ // calendar, group mms, dedupe = 10. eCustomType is excluded by customTypeId.
242
+ const EXPECTED_ACTIVE = 10
243
+
244
+ await async_test(
245
+ "Active count includes exactly the qualifying endusers",
246
+ () => onlyThisTest(),
247
+ { onResult: r => r === EXPECTED_ACTIVE }
248
+ )
249
+
250
+ // --- dedupe ----------------------------------------------------------------------------------
251
+ await async_test(
252
+ "Enduser active on email + sms + chat counts once",
253
+ () => onlyCase('dedupe'),
254
+ { onResult: r => r === 1 }
255
+ )
256
+
257
+ // --- per-channel attribution -----------------------------------------------------------------
258
+ const expectActive = (label: string, apcCase: string, expected: number) => async_test(
259
+ label,
260
+ () => onlyCase(apcCase),
261
+ { onResult: r => r === expected }
262
+ )
263
+
264
+ await expectActive("Inbound email counts", 'email', 1)
265
+ await expectActive("Outbound email without link opens does not count", 'email-outbound', 0)
266
+ await expectActive("Inbound SMS counts", 'sms', 1)
267
+ await expectActive("Outbound SMS does not count", 'sms-outbound', 0)
268
+ await expectActive("Inbound phone call counts", 'call-inbound', 1)
269
+ await expectActive("Outbound phone call >= 60s counts", 'call-long-outbound', 1)
270
+ await expectActive("Outbound phone call < 60s does not count", 'call-short-outbound', 0)
271
+ await expectActive("Chat with enduserId counts", 'chat', 1)
272
+ await expectActive("Chat without enduserId does not count", 'chat-no-enduserid', 0)
273
+ await expectActive("Submitted form response counts", 'form', 1)
274
+ await expectActive("Unsubmitted form response does not count", 'form-unsubmitted', 0)
275
+ await expectActive("Calendar event with enduser attendee counts", 'calendar', 1)
276
+ await expectActive("Calendar event with only user attendees does not count", 'calendar-user-only', 0)
277
+ await expectActive("Inbound group MMS counts", 'group-mms', 1)
278
+ await expectActive("Group MMS sent by a user does not count", 'group-mms-user-sender', 0)
279
+ await expectActive("Enduser with no engagement does not count", 'inactive', 0)
280
+
281
+ // --- date field per collection ----------------------------------------------------------------
282
+ await async_test(
283
+ "Calendar event is filtered by startTimeInMS, not creation time",
284
+ () => onlyCase('calendar-past-start'),
285
+ { onResult: r => r === 0 }
286
+ )
287
+
288
+ // --- range boundaries -------------------------------------------------------------------------
289
+ const afterAll = new Date(Date.now() + 2000)
290
+
291
+ await async_test(
292
+ "Active count is unchanged with an explicit upper bound after all activity",
293
+ () => sdk.api.endusers.get_engagement_statistics({
294
+ range: { from: windowStart, to: afterAll },
295
+ enduserFields: [{ field: 'fields.apcCase', value: '$exists' }],
296
+ }).then(r => r.count),
297
+ { onResult: r => r === EXPECTED_ACTIVE }
298
+ )
299
+ await async_test(
300
+ "Active count is 0 for a range that starts after all activity",
301
+ () => sdk.api.endusers.get_engagement_statistics({ range: { from: afterAll } }).then(r => r.count),
302
+ { onResult: r => r === 0 }
303
+ )
304
+ await async_test(
305
+ "Active count is 0 for a range that ends before all activity",
306
+ () => sdk.api.endusers.get_engagement_statistics({
307
+ range: { from: new Date(windowStart.getTime() - 4000), to: windowStart },
308
+ enduserFields: [{ field: 'fields.apcCase', value: '$exists' }],
309
+ }).then(r => r.count),
310
+ { onResult: r => r === 0 }
311
+ )
312
+
313
+ // --- customTypeId ------------------------------------------------------------------------------
314
+ await async_test(
315
+ "Endusers with a customTypeId are excluded by default",
316
+ () => onlyCase('custom-type'),
317
+ { onResult: r => r === 0 }
318
+ )
319
+ await async_test(
320
+ "Endusers with a customTypeId are included when that customTypeId is requested",
321
+ () => activeCount({ customTypeId: TEST_CUSTOM_TYPE_ID }),
322
+ { onResult: r => r === 1 }
323
+ )
324
+
325
+ // --- formIds ------------------------------------------------------------------------------------
326
+ await async_test(
327
+ "formIds narrows which form responses qualify",
328
+ () => onlyThisTest({ formIds: [formA.id] }),
329
+ { onResult: r => r === EXPECTED_ACTIVE - 1 } // drops the enduser who only submitted form B
330
+ )
331
+
332
+ // --- endusersFilter (a separate code path from enduserFields) -------------------------------------
333
+ await async_test(
334
+ "endusersFilter narrows the counted endusers",
335
+ () => activeCount({ endusersFilter: { 'fields.apcCase': 'email' } }),
336
+ { onResult: r => r === 1 }
337
+ )
338
+
339
+ // --- enduserFields -------------------------------------------------------------------------------
340
+ await async_test(
341
+ "enduserFields matches on a stored field value",
342
+ () => activeCount({ enduserFields: [{ field: 'fields.apcTag', value: 'yes' }] }),
343
+ { onResult: r => r === 3 } // email, sms, dedupe
344
+ )
345
+ await async_test(
346
+ "enduserFields supports the $exists qualifier",
347
+ () => activeCount({ enduserFields: [{ field: 'fields.apcGroup', value: '$exists' }] }),
348
+ { onResult: r => r === EXPECTED_ACTIVE - 1 } // all active endusers except apc-calendar
349
+ )
350
+
351
+ // --- groupBy ---------------------------------------------------------------------------------------
352
+ await async_test(
353
+ "groupBy returns per-group counts summing to the total",
354
+ () => sdk.api.endusers.get_engagement_statistics({
355
+ range: { from: windowStart }, groupBy: 'fields.apcGroup',
356
+ enduserFields: [{ field: 'fields.apcCase', value: '$exists' }],
357
+ }),
358
+ {
359
+ onResult: r => (
360
+ Array.isArray(r.grouped)
361
+ && r.grouped.reduce((sum, g) => sum + g.count, 0) === EXPECTED_ACTIVE
362
+ )
363
+ }
364
+ )
365
+
366
+ // --- endusers cannot read org-wide staff analytics ------------------------------------------------
367
+ const enduserSDK = new EnduserSession({ host, businessId })
368
+ await authenticate_enduser_via_token(sdk, enduserSDK, { id: eEmail.id })
369
+
370
+ await async_test(
371
+ "Endusers cannot call get_engagement_statistics",
372
+ () => enduserSDK._GET('/v1/endusers/engagement', { range: { from: windowStart } }),
373
+ handleAnyError
374
+ )
375
+
376
+ // --- tenant isolation --------------------------------------------------------------------------------
377
+ // The refactor drops restrictAccess in favor of a direct businessId match, so this is the one
378
+ // boundary the function now enforces entirely on its own.
379
+ const sdkOther = new Session({ host, apiKey: OTHER_TENANT_API_KEY })
380
+ const otherWindowStart = new Date(Date.now() - 2000)
381
+ const otherEnduser = await sdkOther.api.endusers.createOne({ fname: 'apc-other-tenant' })
382
+
383
+ try {
384
+ // userId is left to its session initializer — this is an API-key session with no logged-in user
385
+ await sdkOther.api.emails.createOne({
386
+ logOnly: true, inbound: true,
387
+ enduserId: otherEnduser.id, subject: 'apc other tenant', textContent: 'inbound email',
388
+ })
389
+ await wait(undefined, 500)
390
+
391
+ await async_test(
392
+ "Other tenant's engagement does not appear in this tenant's count",
393
+ () => onlyThisTest(),
394
+ { onResult: r => r === EXPECTED_ACTIVE }
395
+ )
396
+ await async_test(
397
+ "This tenant's engagement does not appear in the other tenant's count",
398
+ () => sdkOther.api.endusers.get_engagement_statistics({ range: { from: otherWindowStart } })
399
+ .then(r => r.count),
400
+ { onResult: r => r === 1 }
401
+ )
402
+ } finally {
403
+ await sdkOther.api.endusers.deleteOne(otherEnduser.id).catch(console.error)
404
+ }
405
+ } finally {
406
+ await Promise.all([
407
+ ...createdEnduserIds.map(id => sdk.api.endusers.deleteOne(id).catch(console.error)),
408
+ ...chatRoomIds.map(id => sdk.api.chat_rooms.deleteOne(id).catch(console.error)),
409
+ ...conversationIds.map(id => sdk.api.group_mms_conversations.deleteOne(id).catch(console.error)),
410
+ ...calendarEventIds.map(id => sdk.api.calendar_events.deleteOne(id).catch(console.error)),
411
+ ...formIds.map(id => sdk.api.forms.deleteOne(id).catch(console.error)),
412
+ ])
413
+ }
414
+ }
415
+
416
+ // Allow running this test file independently
417
+ if (require.main === module) {
418
+ const sdk = new Session({ host })
419
+ const sdkNonAdmin = new Session({ host })
420
+
421
+ const runTests = async () => {
422
+ await setup_tests(sdk, sdkNonAdmin)
423
+ await active_patient_count_tests({ sdk, sdkNonAdmin })
424
+ }
425
+
426
+ runTests()
427
+ .then(() => {
428
+ console.log("✅ Active patient count test suite completed successfully")
429
+ process.exit(0)
430
+ })
431
+ .catch((error) => {
432
+ console.error("❌ Active patient count test suite failed:", error)
433
+ process.exit(1)
434
+ })
435
+ }
@@ -0,0 +1,162 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ assert,
6
+ log_header,
7
+ } from "@tellescope/testing"
8
+ import { setup_tests } from "../setup"
9
+
10
+ const host = process.env.API_URL || 'http://localhost:8080' as const
11
+
12
+ const MS_PER_MINUTE = 60 * 1000
13
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE
14
+
15
+ // Covers the calendar_events.out_of_office endpoint, whose whole reason to exist is that it reads
16
+ // ACROSS users: calendar_events is row-filtered on attendees.id and a non-admin defaults to
17
+ // read: 'Assigned', so the ordinary read path returns only the caller's own blocks. The endpoint
18
+ // therefore queries organization-wide on purpose, and returns no event content to make that safe.
19
+ //
20
+ // The cross-user assertion below is the one that matters: swapping the handler's
21
+ // buildOrganizationWideQueries back to the request-scoped DB would silently break peer visibility in
22
+ // Team Chat without breaking a build or any other test.
23
+ export const out_of_office_endpoint_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
24
+ log_header("Out of Office Endpoint")
25
+
26
+ const adminId = sdk.userInfo.id
27
+ const nonAdminId = sdkNonAdmin.userInfo.id
28
+ const now = Date.now()
29
+
30
+ // active right now, on the ADMIN, so the non-admin is neither an attendee nor the creator
31
+ const active = await sdk.api.calendar_events.createOne({
32
+ title: 'Out of Office',
33
+ outOfOffice: true,
34
+ startTimeInMS: now - 2 * MS_PER_HOUR,
35
+ durationInMinutes: 8 * 60,
36
+ attendees: [{ id: adminId, type: 'user' }],
37
+ })
38
+ // ended three hours ago — inside the query's start-time range, so only the handler's computed
39
+ // end-time check excludes it
40
+ const ended = await sdk.api.calendar_events.createOne({
41
+ title: 'Out of Office',
42
+ outOfOffice: true,
43
+ startTimeInMS: now - 5 * MS_PER_HOUR,
44
+ durationInMinutes: 2 * 60,
45
+ attendees: [{ id: adminId, type: 'user' }],
46
+ })
47
+ const cancelled = await sdk.api.calendar_events.createOne({
48
+ title: 'Out of Office',
49
+ outOfOffice: true,
50
+ startTimeInMS: now - MS_PER_HOUR,
51
+ durationInMinutes: 4 * 60,
52
+ attendees: [{ id: adminId, type: 'user' }],
53
+ cancelledAt: new Date(),
54
+ })
55
+ // not flagged: a normal event overlapping now must never appear
56
+ const normal = await sdk.api.calendar_events.createOne({
57
+ title: 'Regular Appointment',
58
+ startTimeInMS: now - MS_PER_HOUR,
59
+ durationInMinutes: 4 * 60,
60
+ attendees: [{ id: adminId, type: 'user' }],
61
+ })
62
+ // on the non-admin, used for the userIds scoping check
63
+ const nonAdminOwn = await sdk.api.calendar_events.createOne({
64
+ title: 'Out of Office',
65
+ outOfOffice: true,
66
+ startTimeInMS: now - MS_PER_HOUR,
67
+ durationInMinutes: 4 * 60,
68
+ attendees: [{ id: nonAdminId, type: 'user' }],
69
+ })
70
+
71
+ try {
72
+ const { outOfOffice } = await sdkNonAdmin.api.calendar_events.out_of_office()
73
+
74
+ // assert(assertion, failureMessage, successTitle)
75
+ assert(
76
+ !!outOfOffice.find(o => o.userId === adminId && o.startTimeInMS === active.startTimeInMS),
77
+ 'non-admin does NOT see a peer out of office window — is the handler using the request-scoped DB instead of buildOrganizationWideQueries?',
78
+ 'non-admin sees a peer out of office window',
79
+ )
80
+
81
+ assert(
82
+ outOfOffice.every(o => (
83
+ Object.keys(o).sort().join(',') === 'endTimeInMS,startTimeInMS,userId'
84
+ )),
85
+ `response leaked event fields: ${JSON.stringify(outOfOffice[0] ?? {})}`,
86
+ 'response carries no event content',
87
+ )
88
+
89
+ const forAdmin = outOfOffice.filter(o => o.userId === adminId)
90
+ assert(
91
+ forAdmin.length === 1,
92
+ `expected exactly the one active window for the admin, got ${forAdmin.length}: ${JSON.stringify(forAdmin)}`,
93
+ 'ended, cancelled and unflagged events are excluded',
94
+ )
95
+ assert(
96
+ forAdmin[0]?.endTimeInMS === active.startTimeInMS + active.durationInMinutes * MS_PER_MINUTE,
97
+ `endTimeInMS was ${forAdmin[0]?.endTimeInMS}, expected ${active.startTimeInMS + active.durationInMinutes * MS_PER_MINUTE}`,
98
+ 'endTimeInMS is resolved from startTimeInMS + durationInMinutes',
99
+ )
100
+
101
+ // An admin's reads are unrestricted anyway, so this passes even if the handler were reverted to
102
+ // the request-scoped DB — it's the non-admin assertion above that pins the design. Kept as the
103
+ // pair, so the two together show the result is role-independent rather than role-dependent.
104
+ const asAdmin = await sdk.api.calendar_events.out_of_office()
105
+ assert(
106
+ !!asAdmin.outOfOffice.find(o => o.userId === adminId && o.startTimeInMS === active.startTimeInMS)
107
+ && !!asAdmin.outOfOffice.find(o => o.userId === nonAdminId),
108
+ `admin load missing windows: ${JSON.stringify(asAdmin.outOfOffice)}`,
109
+ 'admin sees both its own and a peer out of office window',
110
+ )
111
+ assert(
112
+ asAdmin.outOfOffice.filter(o => o.userId === adminId).length === forAdmin.length,
113
+ `admin and non-admin disagree on the admin's windows: ${JSON.stringify(asAdmin.outOfOffice.filter(o => o.userId === adminId))} vs ${JSON.stringify(forAdmin)}`,
114
+ 'admin and non-admin loads return the same windows',
115
+ )
116
+
117
+ const scoped = await sdkNonAdmin.api.calendar_events.out_of_office({ userIds: [nonAdminId] })
118
+ assert(
119
+ scoped.outOfOffice.length === 1 && scoped.outOfOffice[0].userId === nonAdminId,
120
+ `expected only the requested user, got ${JSON.stringify(scoped.outOfOffice)}`,
121
+ 'userIds scopes the result',
122
+ )
123
+
124
+ // a real ObjectId that belongs to no user in this business
125
+ const foreign = await sdkNonAdmin.api.calendar_events.out_of_office({ userIds: ['5fdb3d4a4e6e2a0016a1a1a1'] })
126
+ assert(
127
+ foreign.outOfOffice.length === 0,
128
+ `expected empty for unknown userIds, got ${JSON.stringify(foreign.outOfOffice)}`,
129
+ 'unknown userIds return nothing',
130
+ )
131
+ } finally {
132
+ await Promise.all([
133
+ sdk.api.calendar_events.deleteOne(active.id),
134
+ sdk.api.calendar_events.deleteOne(ended.id),
135
+ sdk.api.calendar_events.deleteOne(cancelled.id),
136
+ sdk.api.calendar_events.deleteOne(normal.id),
137
+ sdk.api.calendar_events.deleteOne(nonAdminOwn.id),
138
+ ])
139
+ }
140
+ }
141
+
142
+ // Allow running this test file independently
143
+ if (require.main === module) {
144
+ console.log(`🌐 Using API URL: ${host}`)
145
+ const sdk = new Session({ host })
146
+ const sdkNonAdmin = new Session({ host })
147
+
148
+ const runTests = async () => {
149
+ await setup_tests(sdk, sdkNonAdmin)
150
+ await out_of_office_endpoint_tests({ sdk, sdkNonAdmin })
151
+ }
152
+
153
+ runTests()
154
+ .then(() => {
155
+ console.log("✅ Out of office endpoint test suite completed successfully")
156
+ process.exit(0)
157
+ })
158
+ .catch((error) => {
159
+ console.error("❌ Out of office endpoint test suite failed:", error)
160
+ process.exit(1)
161
+ })
162
+ }