@clipin/convex-wearables 0.0.2 → 0.0.3
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.
- package/dist/client/index.d.ts +9 -4
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +50 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/backfillJobs.d.ts +11 -11
- package/dist/component/connections.d.ts +9 -9
- package/dist/component/connections.d.ts.map +1 -1
- package/dist/component/connections.js +2 -0
- package/dist/component/connections.js.map +1 -1
- package/dist/component/dataPoints.d.ts +5 -5
- package/dist/component/events.d.ts +13 -13
- package/dist/component/garminBackfill.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts.map +1 -1
- package/dist/component/garminWebhooks.js +2 -0
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/lifecycle.d.ts +1 -1
- package/dist/component/lifecycle.d.ts.map +1 -1
- package/dist/component/lifecycle.js +2 -0
- package/dist/component/lifecycle.js.map +1 -1
- package/dist/component/oauthStates.d.ts +3 -3
- package/dist/component/schema.d.ts +26 -26
- package/dist/component/sdkPush.d.ts +11 -11
- package/dist/component/summaries.d.ts +4 -4
- package/dist/component/syncJobs.d.ts +23 -23
- package/dist/component/syncWorkflow.d.ts +2 -2
- package/dist/test.d.ts +421 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +17 -0
- package/dist/test.js.map +1 -0
- package/package.json +12 -2
- package/src/client/_generated/_ignore.ts +2 -0
- package/src/client/index.test.ts +52 -0
- package/src/client/index.ts +784 -0
- package/src/client/types.ts +533 -0
- package/src/component/_generated/_ignore.ts +2 -0
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +74 -0
- package/src/component/_generated/dataModel.ts +40 -0
- package/src/component/_generated/server.ts +48 -0
- package/src/component/backfillJobs.test.ts +47 -0
- package/src/component/backfillJobs.ts +245 -0
- package/src/component/connections.test.ts +297 -0
- package/src/component/connections.ts +329 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/dataPoints.test.ts +282 -0
- package/src/component/dataPoints.ts +305 -0
- package/src/component/dataSources.test.ts +247 -0
- package/src/component/dataSources.ts +109 -0
- package/src/component/events.test.ts +380 -0
- package/src/component/events.ts +288 -0
- package/src/component/garminBackfill.ts +343 -0
- package/src/component/garminWebhooks.test.ts +609 -0
- package/src/component/garminWebhooks.ts +656 -0
- package/src/component/httpHandlers.ts +153 -0
- package/src/component/lifecycle.test.ts +179 -0
- package/src/component/lifecycle.ts +87 -0
- package/src/component/menstrualCycles.ts +124 -0
- package/src/component/oauthActions.ts +261 -0
- package/src/component/oauthStates.test.ts +170 -0
- package/src/component/oauthStates.ts +85 -0
- package/src/component/providerSettings.ts +66 -0
- package/src/component/providers/additionalProviders.test.ts +401 -0
- package/src/component/providers/garmin.ts +1169 -0
- package/src/component/providers/oauth.test.ts +174 -0
- package/src/component/providers/oauth.ts +246 -0
- package/src/component/providers/polar.ts +220 -0
- package/src/component/providers/registry.ts +37 -0
- package/src/component/providers/strava.test.ts +195 -0
- package/src/component/providers/strava.ts +253 -0
- package/src/component/providers/suunto.ts +592 -0
- package/src/component/providers/types.ts +189 -0
- package/src/component/providers/whoop.ts +600 -0
- package/src/component/schema.ts +339 -0
- package/src/component/sdkPush.test.ts +367 -0
- package/src/component/sdkPush.ts +440 -0
- package/src/component/summaries.test.ts +201 -0
- package/src/component/summaries.ts +143 -0
- package/src/component/syncJobs.test.ts +254 -0
- package/src/component/syncJobs.ts +140 -0
- package/src/component/syncWorkflow.test.ts +87 -0
- package/src/component/syncWorkflow.ts +739 -0
- package/src/component/test.setup.ts +6 -0
- package/src/component/workflowManager.ts +19 -0
- package/src/test.ts +25 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Garmin webhook processing.
|
|
3
|
+
*
|
|
4
|
+
* Garmin pushes data to registered webhook endpoints. This module
|
|
5
|
+
* provides actions that process the push payloads and store the data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { v } from "convex/values";
|
|
9
|
+
import { api, internal } from "./_generated/api";
|
|
10
|
+
import type { Doc, Id } from "./_generated/dataModel";
|
|
11
|
+
import { type ActionCtx, action } from "./_generated/server";
|
|
12
|
+
import {
|
|
13
|
+
type GarminPushPayload,
|
|
14
|
+
normalizeActivity,
|
|
15
|
+
normalizeBloodPressureDataPoints,
|
|
16
|
+
normalizeBodyCompositionDataPoints,
|
|
17
|
+
normalizeBodyCompositionSummary,
|
|
18
|
+
normalizeDaily,
|
|
19
|
+
normalizeDailyRecoverySummary,
|
|
20
|
+
normalizeEpochDataPoints,
|
|
21
|
+
normalizeHealthSnapshotDataPoints,
|
|
22
|
+
normalizeHrvDataPoints,
|
|
23
|
+
normalizeHrvSummary,
|
|
24
|
+
normalizeMCT,
|
|
25
|
+
normalizeMoveIQ,
|
|
26
|
+
normalizePulseOxDataPoints,
|
|
27
|
+
normalizePulseOxSummary,
|
|
28
|
+
normalizeRespirationDataPoints,
|
|
29
|
+
normalizeSkinTemperatureDataPoints,
|
|
30
|
+
normalizeSleep,
|
|
31
|
+
normalizeSleepSummary,
|
|
32
|
+
normalizeStressDataPoints,
|
|
33
|
+
normalizeUserMetricsDataPoints,
|
|
34
|
+
} from "./providers/garmin";
|
|
35
|
+
|
|
36
|
+
const DATA_POINT_BATCH_SIZE = 100;
|
|
37
|
+
|
|
38
|
+
function decodePushPayload(args: { payload?: unknown; payloadJson?: string }): GarminPushPayload {
|
|
39
|
+
if (args.payloadJson !== undefined) {
|
|
40
|
+
return JSON.parse(args.payloadJson) as GarminPushPayload;
|
|
41
|
+
}
|
|
42
|
+
if (args.payload !== undefined) {
|
|
43
|
+
return args.payload as GarminPushPayload;
|
|
44
|
+
}
|
|
45
|
+
throw new Error("Garmin push payload is required");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Process a Garmin push webhook payload.
|
|
50
|
+
*
|
|
51
|
+
* Handles activities, sleep, dailies, and extended wellness feeds.
|
|
52
|
+
*/
|
|
53
|
+
export const processPushPayload = action({
|
|
54
|
+
args: {
|
|
55
|
+
payload: v.optional(v.any()),
|
|
56
|
+
payloadJson: v.optional(v.string()),
|
|
57
|
+
garminClientId: v.string(),
|
|
58
|
+
},
|
|
59
|
+
returns: v.null(),
|
|
60
|
+
handler: async (ctx, args) => {
|
|
61
|
+
const payload = decodePushPayload(args);
|
|
62
|
+
const signalBuckets = new Map<string, Set<string>>();
|
|
63
|
+
|
|
64
|
+
await processActivityEntries(ctx, payload.activities, "activities", signalBuckets);
|
|
65
|
+
await processActivityEntries(ctx, payload.activityDetails, "activityDetails", signalBuckets);
|
|
66
|
+
|
|
67
|
+
if (payload.sleeps?.length) {
|
|
68
|
+
for (const sleep of payload.sleeps) {
|
|
69
|
+
const connection = await resolveConnection(ctx, sleep.userId);
|
|
70
|
+
if (!connection) continue;
|
|
71
|
+
|
|
72
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
73
|
+
if (!dataSourceId) continue;
|
|
74
|
+
|
|
75
|
+
const event = normalizeSleep(sleep);
|
|
76
|
+
await ctx.runMutation(internal.events.storeEvent, {
|
|
77
|
+
dataSourceId,
|
|
78
|
+
userId: connection.userId,
|
|
79
|
+
category: event.category,
|
|
80
|
+
type: event.type,
|
|
81
|
+
sourceName: event.sourceName,
|
|
82
|
+
durationSeconds: event.durationSeconds,
|
|
83
|
+
startDatetime: event.startDatetime,
|
|
84
|
+
endDatetime: event.endDatetime,
|
|
85
|
+
externalId: event.externalId,
|
|
86
|
+
heartRateAvg: event.heartRateAvg,
|
|
87
|
+
heartRateMin: event.heartRateMin,
|
|
88
|
+
sleepTotalDurationMinutes: event.sleepTotalDurationMinutes,
|
|
89
|
+
sleepDeepMinutes: event.sleepDeepMinutes,
|
|
90
|
+
sleepLightMinutes: event.sleepLightMinutes,
|
|
91
|
+
sleepRemMinutes: event.sleepRemMinutes,
|
|
92
|
+
sleepAwakeMinutes: event.sleepAwakeMinutes,
|
|
93
|
+
sleepEfficiencyScore: event.sleepEfficiencyScore,
|
|
94
|
+
sleepStages: event.sleepStages,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await upsertSummary(ctx, connection.userId, normalizeSleepSummary(sleep));
|
|
98
|
+
|
|
99
|
+
const supplementalPoints = [];
|
|
100
|
+
if (sleep.avgOxygenSaturation != null) {
|
|
101
|
+
supplementalPoints.push({
|
|
102
|
+
seriesType: "oxygen_saturation",
|
|
103
|
+
recordedAt: sleep.startTimeInSeconds * 1000,
|
|
104
|
+
value: sleep.avgOxygenSaturation,
|
|
105
|
+
externalId: `${sleep.summaryId}:sleep_spo2`,
|
|
106
|
+
});
|
|
107
|
+
await upsertSummary(ctx, connection.userId, {
|
|
108
|
+
date: isoDateFromTimestamp(sleep.startTimeInSeconds * 1000),
|
|
109
|
+
category: "recovery",
|
|
110
|
+
spo2Avg: sleep.avgOxygenSaturation,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (sleep.respirationAvg != null) {
|
|
114
|
+
supplementalPoints.push({
|
|
115
|
+
seriesType: "respiratory_rate",
|
|
116
|
+
recordedAt: sleep.startTimeInSeconds * 1000,
|
|
117
|
+
value: sleep.respirationAvg,
|
|
118
|
+
externalId: `${sleep.summaryId}:sleep_respiration`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
await storeNormalizedDataPoints(ctx, dataSourceId, supplementalPoints);
|
|
122
|
+
|
|
123
|
+
addSignal(signalBuckets, "sleeps", connection._id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (payload.dailies?.length) {
|
|
128
|
+
for (const daily of payload.dailies) {
|
|
129
|
+
const connection = await resolveConnection(ctx, daily.userId);
|
|
130
|
+
if (!connection) continue;
|
|
131
|
+
|
|
132
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
133
|
+
if (!dataSourceId) continue;
|
|
134
|
+
|
|
135
|
+
const normalized = normalizeDaily(daily);
|
|
136
|
+
|
|
137
|
+
await upsertSummary(ctx, connection.userId, {
|
|
138
|
+
date: normalized.date,
|
|
139
|
+
category: "activity",
|
|
140
|
+
totalSteps: normalized.totalSteps,
|
|
141
|
+
totalCalories: normalized.totalCalories,
|
|
142
|
+
activeCalories: normalized.activeCalories,
|
|
143
|
+
totalDistance: normalized.totalDistance,
|
|
144
|
+
floorsClimbed: normalized.floorsClimbed,
|
|
145
|
+
avgHeartRate: normalized.avgHeartRate,
|
|
146
|
+
maxHeartRate: normalized.maxHeartRate,
|
|
147
|
+
minHeartRate: normalized.minHeartRate,
|
|
148
|
+
activeMinutes: normalized.activeMinutes,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await upsertSummary(ctx, connection.userId, normalizeDailyRecoverySummary(daily));
|
|
152
|
+
|
|
153
|
+
const points = [];
|
|
154
|
+
if (normalized.restingHeartRate != null) {
|
|
155
|
+
points.push({
|
|
156
|
+
seriesType: "resting_heart_rate",
|
|
157
|
+
recordedAt: daily.startTimeInSeconds * 1000,
|
|
158
|
+
value: normalized.restingHeartRate,
|
|
159
|
+
externalId: daily.summaryId ? `${daily.summaryId}:resting_heart_rate` : undefined,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (normalized.heartRateSamples?.length) {
|
|
163
|
+
points.push(
|
|
164
|
+
...normalized.heartRateSamples.map((sample) => ({
|
|
165
|
+
seriesType: "heart_rate",
|
|
166
|
+
recordedAt: sample.timestamp,
|
|
167
|
+
value: sample.value,
|
|
168
|
+
})),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
await storeNormalizedDataPoints(ctx, dataSourceId, points);
|
|
172
|
+
|
|
173
|
+
addSignal(signalBuckets, "dailies", connection._id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (payload.epochs?.length) {
|
|
178
|
+
for (const epoch of payload.epochs) {
|
|
179
|
+
const connection = await resolveConnection(ctx, epoch.userId);
|
|
180
|
+
if (!connection) continue;
|
|
181
|
+
|
|
182
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
183
|
+
if (!dataSourceId) continue;
|
|
184
|
+
|
|
185
|
+
await storeNormalizedDataPoints(ctx, dataSourceId, normalizeEpochDataPoints(epoch));
|
|
186
|
+
addSignal(signalBuckets, "epochs", connection._id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (payload.bodyComps?.length) {
|
|
191
|
+
for (const bodyComp of payload.bodyComps) {
|
|
192
|
+
const connection = await resolveConnection(ctx, bodyComp.userId);
|
|
193
|
+
if (!connection) continue;
|
|
194
|
+
|
|
195
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
196
|
+
if (!dataSourceId) continue;
|
|
197
|
+
|
|
198
|
+
await storeNormalizedDataPoints(
|
|
199
|
+
ctx,
|
|
200
|
+
dataSourceId,
|
|
201
|
+
normalizeBodyCompositionDataPoints(bodyComp),
|
|
202
|
+
);
|
|
203
|
+
await upsertSummary(ctx, connection.userId, normalizeBodyCompositionSummary(bodyComp));
|
|
204
|
+
|
|
205
|
+
addSignal(signalBuckets, "bodyComps", connection._id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (payload.hrv?.length) {
|
|
210
|
+
for (const hrv of payload.hrv) {
|
|
211
|
+
const connection = await resolveConnection(ctx, hrv.userId);
|
|
212
|
+
if (!connection) continue;
|
|
213
|
+
|
|
214
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
215
|
+
if (!dataSourceId) continue;
|
|
216
|
+
|
|
217
|
+
await storeNormalizedDataPoints(ctx, dataSourceId, normalizeHrvDataPoints(hrv));
|
|
218
|
+
await upsertSummary(ctx, connection.userId, normalizeHrvSummary(hrv));
|
|
219
|
+
|
|
220
|
+
addSignal(signalBuckets, "hrv", connection._id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (payload.stressDetails?.length) {
|
|
225
|
+
for (const stress of payload.stressDetails) {
|
|
226
|
+
const connection = await resolveConnection(ctx, stress.userId);
|
|
227
|
+
if (!connection) continue;
|
|
228
|
+
|
|
229
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
230
|
+
if (!dataSourceId) continue;
|
|
231
|
+
|
|
232
|
+
await storeNormalizedDataPoints(ctx, dataSourceId, normalizeStressDataPoints(stress));
|
|
233
|
+
addSignal(signalBuckets, "stressDetails", connection._id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (payload.respiration?.length) {
|
|
238
|
+
for (const respiration of payload.respiration) {
|
|
239
|
+
const connection = await resolveConnection(ctx, respiration.userId);
|
|
240
|
+
if (!connection) continue;
|
|
241
|
+
|
|
242
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
243
|
+
if (!dataSourceId) continue;
|
|
244
|
+
|
|
245
|
+
await storeNormalizedDataPoints(
|
|
246
|
+
ctx,
|
|
247
|
+
dataSourceId,
|
|
248
|
+
normalizeRespirationDataPoints(respiration),
|
|
249
|
+
);
|
|
250
|
+
addSignal(signalBuckets, "respiration", connection._id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (payload.pulseOx?.length) {
|
|
255
|
+
for (const pulseOx of payload.pulseOx) {
|
|
256
|
+
const connection = await resolveConnection(ctx, pulseOx.userId);
|
|
257
|
+
if (!connection) continue;
|
|
258
|
+
|
|
259
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
260
|
+
if (!dataSourceId) continue;
|
|
261
|
+
|
|
262
|
+
await storeNormalizedDataPoints(ctx, dataSourceId, normalizePulseOxDataPoints(pulseOx));
|
|
263
|
+
await upsertSummary(ctx, connection.userId, normalizePulseOxSummary(pulseOx));
|
|
264
|
+
|
|
265
|
+
addSignal(signalBuckets, "pulseOx", connection._id);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (payload.bloodPressures?.length) {
|
|
270
|
+
for (const bloodPressure of payload.bloodPressures) {
|
|
271
|
+
const connection = await resolveConnection(ctx, bloodPressure.userId);
|
|
272
|
+
if (!connection) continue;
|
|
273
|
+
|
|
274
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
275
|
+
if (!dataSourceId) continue;
|
|
276
|
+
|
|
277
|
+
await storeNormalizedDataPoints(
|
|
278
|
+
ctx,
|
|
279
|
+
dataSourceId,
|
|
280
|
+
normalizeBloodPressureDataPoints(bloodPressure),
|
|
281
|
+
);
|
|
282
|
+
addSignal(signalBuckets, "bloodPressures", connection._id);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (payload.userMetrics?.length) {
|
|
287
|
+
for (const userMetrics of payload.userMetrics) {
|
|
288
|
+
const connection = await resolveConnection(ctx, userMetrics.userId);
|
|
289
|
+
if (!connection) continue;
|
|
290
|
+
|
|
291
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
292
|
+
if (!dataSourceId) continue;
|
|
293
|
+
|
|
294
|
+
await storeNormalizedDataPoints(
|
|
295
|
+
ctx,
|
|
296
|
+
dataSourceId,
|
|
297
|
+
normalizeUserMetricsDataPoints(userMetrics),
|
|
298
|
+
);
|
|
299
|
+
addSignal(signalBuckets, "userMetrics", connection._id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (payload.skinTemp?.length) {
|
|
304
|
+
for (const skinTemp of payload.skinTemp) {
|
|
305
|
+
const connection = await resolveConnection(ctx, skinTemp.userId);
|
|
306
|
+
if (!connection) continue;
|
|
307
|
+
|
|
308
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
309
|
+
if (!dataSourceId) continue;
|
|
310
|
+
|
|
311
|
+
await storeNormalizedDataPoints(
|
|
312
|
+
ctx,
|
|
313
|
+
dataSourceId,
|
|
314
|
+
normalizeSkinTemperatureDataPoints(skinTemp),
|
|
315
|
+
);
|
|
316
|
+
addSignal(signalBuckets, "skinTemp", connection._id);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (payload.healthSnapshot?.length) {
|
|
321
|
+
for (const snapshot of payload.healthSnapshot) {
|
|
322
|
+
const connection = await resolveConnection(ctx, snapshot.userId);
|
|
323
|
+
if (!connection) continue;
|
|
324
|
+
|
|
325
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
326
|
+
if (!dataSourceId) continue;
|
|
327
|
+
|
|
328
|
+
await storeNormalizedDataPoints(
|
|
329
|
+
ctx,
|
|
330
|
+
dataSourceId,
|
|
331
|
+
normalizeHealthSnapshotDataPoints(snapshot),
|
|
332
|
+
);
|
|
333
|
+
addSignal(signalBuckets, "healthSnapshot", connection._id);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (payload.moveiq?.length) {
|
|
338
|
+
for (const moveIQ of payload.moveiq) {
|
|
339
|
+
const connection = await resolveConnection(ctx, moveIQ.userId);
|
|
340
|
+
if (!connection) continue;
|
|
341
|
+
|
|
342
|
+
const dataSourceId = await resolveDataSource(ctx, connection);
|
|
343
|
+
if (!dataSourceId) continue;
|
|
344
|
+
|
|
345
|
+
const event = normalizeMoveIQ(moveIQ);
|
|
346
|
+
await ctx.runMutation(internal.events.storeEvent, {
|
|
347
|
+
dataSourceId,
|
|
348
|
+
userId: connection.userId,
|
|
349
|
+
category: event.category,
|
|
350
|
+
type: event.type,
|
|
351
|
+
sourceName: event.sourceName,
|
|
352
|
+
durationSeconds: event.durationSeconds,
|
|
353
|
+
startDatetime: event.startDatetime,
|
|
354
|
+
endDatetime: event.endDatetime,
|
|
355
|
+
externalId: event.externalId,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
addSignal(signalBuckets, "moveiq", connection._id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const menstrualCycleTracking =
|
|
363
|
+
payload.menstrualCycleTracking && payload.menstrualCycleTracking.length > 0
|
|
364
|
+
? payload.menstrualCycleTracking
|
|
365
|
+
: payload.mct;
|
|
366
|
+
if (menstrualCycleTracking?.length) {
|
|
367
|
+
for (const mct of menstrualCycleTracking) {
|
|
368
|
+
const connection = await resolveConnection(ctx, mct.userId);
|
|
369
|
+
if (!connection) continue;
|
|
370
|
+
|
|
371
|
+
const normalized = normalizeMCT(mct);
|
|
372
|
+
await ctx.runMutation(internal.menstrualCycles.upsert, {
|
|
373
|
+
userId: connection.userId,
|
|
374
|
+
provider: "garmin" as const,
|
|
375
|
+
externalId: normalized.externalId,
|
|
376
|
+
periodStartDate: normalized.periodStartDate,
|
|
377
|
+
dayInCycle: normalized.dayInCycle,
|
|
378
|
+
cycleLength: normalized.cycleLength,
|
|
379
|
+
predictedCycleLength: normalized.predictedCycleLength,
|
|
380
|
+
periodLength: normalized.periodLength,
|
|
381
|
+
currentPhase: normalized.currentPhase,
|
|
382
|
+
currentPhaseType: normalized.currentPhaseType,
|
|
383
|
+
lengthOfCurrentPhase: normalized.lengthOfCurrentPhase,
|
|
384
|
+
daysUntilNextPhase: normalized.daysUntilNextPhase,
|
|
385
|
+
isPredictedCycle: normalized.isPredictedCycle,
|
|
386
|
+
fertileWindowStart: normalized.fertileWindowStart,
|
|
387
|
+
lengthOfFertileWindow: normalized.lengthOfFertileWindow,
|
|
388
|
+
lastUpdatedAt: normalized.lastUpdatedAt,
|
|
389
|
+
isPregnant: normalized.isPregnant,
|
|
390
|
+
pregnancyDueDate: normalized.pregnancyDueDate,
|
|
391
|
+
pregnancyOriginalDueDate: normalized.pregnancyOriginalDueDate,
|
|
392
|
+
pregnancyCycleStartDate: normalized.pregnancyCycleStartDate,
|
|
393
|
+
pregnancyTitle: normalized.pregnancyTitle,
|
|
394
|
+
numberOfBabies: normalized.numberOfBabies,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
addSignal(signalBuckets, "mct", connection._id);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (payload.userPermissionsChange?.length) {
|
|
402
|
+
for (const change of payload.userPermissionsChange) {
|
|
403
|
+
const connection = await resolveConnection(ctx, change.userId);
|
|
404
|
+
if (!connection) continue;
|
|
405
|
+
|
|
406
|
+
await ctx.runMutation(internal.connections.updateScope, {
|
|
407
|
+
connectionId: connection._id,
|
|
408
|
+
scope:
|
|
409
|
+
change.permissions.length > 0 ? [...change.permissions].sort().join(" ") : undefined,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (payload.deregistrations?.length) {
|
|
415
|
+
for (const dereg of payload.deregistrations) {
|
|
416
|
+
const connection = await resolveConnection(ctx, dereg.userId);
|
|
417
|
+
if (!connection) continue;
|
|
418
|
+
|
|
419
|
+
await ctx.runMutation(internal.connections.updateStatus, {
|
|
420
|
+
connectionId: connection._id,
|
|
421
|
+
status: "revoked",
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (const [dataType, connectionIds] of signalBuckets) {
|
|
427
|
+
const itemCount = getPayloadItemCount(payload, dataType);
|
|
428
|
+
for (const connectionId of connectionIds) {
|
|
429
|
+
await ctx.runMutation(internal.backfillJobs.signalWebhookData, {
|
|
430
|
+
connectionId: connectionId as Id<"connections">,
|
|
431
|
+
dataType,
|
|
432
|
+
itemCount,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return null;
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
async function processActivityEntries(
|
|
442
|
+
ctx: Pick<ActionCtx, "runQuery" | "runMutation">,
|
|
443
|
+
activities: GarminPushPayload["activities"] | GarminPushPayload["activityDetails"],
|
|
444
|
+
dataType: "activities" | "activityDetails",
|
|
445
|
+
signalBuckets: Map<string, Set<string>>,
|
|
446
|
+
) {
|
|
447
|
+
if (!activities?.length) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const activity of activities) {
|
|
452
|
+
const connection = await resolveConnection(ctx, activity.userId);
|
|
453
|
+
if (!connection) continue;
|
|
454
|
+
|
|
455
|
+
const dataSourceId = await resolveDataSource(
|
|
456
|
+
ctx,
|
|
457
|
+
connection,
|
|
458
|
+
activity.summary?.deviceName ?? activity.deviceName,
|
|
459
|
+
);
|
|
460
|
+
if (!dataSourceId) continue;
|
|
461
|
+
|
|
462
|
+
const event = normalizeActivity(activity);
|
|
463
|
+
if (!event) {
|
|
464
|
+
console.warn("Skipping Garmin activity with invalid timing", {
|
|
465
|
+
dataType,
|
|
466
|
+
activityId: activity.activityId,
|
|
467
|
+
summaryId: activity.summaryId ?? activity.summary?.summaryId,
|
|
468
|
+
startTimeInSeconds: activity.summary?.startTimeInSeconds ?? activity.startTimeInSeconds,
|
|
469
|
+
durationInSeconds: activity.summary?.durationInSeconds ?? activity.durationInSeconds,
|
|
470
|
+
});
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await ctx.runMutation(internal.events.storeEvent, {
|
|
475
|
+
dataSourceId,
|
|
476
|
+
userId: connection.userId,
|
|
477
|
+
category: event.category,
|
|
478
|
+
type: event.type,
|
|
479
|
+
sourceName: event.sourceName,
|
|
480
|
+
durationSeconds: event.durationSeconds,
|
|
481
|
+
startDatetime: event.startDatetime,
|
|
482
|
+
endDatetime: event.endDatetime,
|
|
483
|
+
externalId: event.externalId,
|
|
484
|
+
heartRateAvg: event.heartRateAvg,
|
|
485
|
+
heartRateMax: event.heartRateMax,
|
|
486
|
+
energyBurned: event.energyBurned,
|
|
487
|
+
distance: event.distance,
|
|
488
|
+
stepsCount: event.stepsCount,
|
|
489
|
+
averageSpeed: event.averageSpeed,
|
|
490
|
+
maxSpeed: event.maxSpeed,
|
|
491
|
+
averageWatts: event.averageWatts,
|
|
492
|
+
maxWatts: event.maxWatts,
|
|
493
|
+
totalElevationGain: event.totalElevationGain,
|
|
494
|
+
movingTimeSeconds: event.movingTimeSeconds,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
addSignal(signalBuckets, dataType, connection._id);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function storeNormalizedDataPoints(
|
|
502
|
+
ctx: Pick<ActionCtx, "runMutation">,
|
|
503
|
+
dataSourceId: Id<"dataSources">,
|
|
504
|
+
points: Array<{
|
|
505
|
+
seriesType: string;
|
|
506
|
+
recordedAt: number;
|
|
507
|
+
value: number;
|
|
508
|
+
externalId?: string;
|
|
509
|
+
}>,
|
|
510
|
+
) {
|
|
511
|
+
if (points.length === 0) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const grouped = new Map<
|
|
516
|
+
string,
|
|
517
|
+
Array<{ recordedAt: number; value: number; externalId?: string }>
|
|
518
|
+
>();
|
|
519
|
+
|
|
520
|
+
for (const point of points) {
|
|
521
|
+
const existing = grouped.get(point.seriesType) ?? [];
|
|
522
|
+
existing.push({
|
|
523
|
+
recordedAt: point.recordedAt,
|
|
524
|
+
value: point.value,
|
|
525
|
+
externalId: point.externalId,
|
|
526
|
+
});
|
|
527
|
+
grouped.set(point.seriesType, existing);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
for (const [seriesType, seriesPoints] of grouped) {
|
|
531
|
+
for (let i = 0; i < seriesPoints.length; i += DATA_POINT_BATCH_SIZE) {
|
|
532
|
+
await ctx.runMutation(internal.dataPoints.storeBatch, {
|
|
533
|
+
dataSourceId,
|
|
534
|
+
seriesType,
|
|
535
|
+
points: seriesPoints.slice(i, i + DATA_POINT_BATCH_SIZE),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function upsertSummary<T extends { date: string; category: string }>(
|
|
542
|
+
ctx: Pick<ActionCtx, "runMutation">,
|
|
543
|
+
userId: string,
|
|
544
|
+
summary: T | null,
|
|
545
|
+
) {
|
|
546
|
+
if (!summary) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const { date, category, ...metrics } = summary;
|
|
551
|
+
if (typeof date !== "string" || typeof category !== "string") {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const hasMetrics = Object.values(metrics).some((value) => value !== undefined && value !== null);
|
|
556
|
+
if (!hasMetrics) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
await ctx.runMutation(internal.summaries.upsert, {
|
|
561
|
+
userId,
|
|
562
|
+
date,
|
|
563
|
+
category,
|
|
564
|
+
...metrics,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function addSignal(
|
|
569
|
+
signalBuckets: Map<string, Set<string>>,
|
|
570
|
+
dataType: string,
|
|
571
|
+
connectionId: string,
|
|
572
|
+
) {
|
|
573
|
+
const entries = signalBuckets.get(dataType) ?? new Set<string>();
|
|
574
|
+
entries.add(connectionId);
|
|
575
|
+
signalBuckets.set(dataType, entries);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function getPayloadItemCount(payload: GarminPushPayload, dataType: string): number | undefined {
|
|
579
|
+
switch (dataType) {
|
|
580
|
+
case "activities":
|
|
581
|
+
return payload.activities?.length;
|
|
582
|
+
case "activityDetails":
|
|
583
|
+
return payload.activityDetails?.length;
|
|
584
|
+
case "sleeps":
|
|
585
|
+
return payload.sleeps?.length;
|
|
586
|
+
case "dailies":
|
|
587
|
+
return payload.dailies?.length;
|
|
588
|
+
case "epochs":
|
|
589
|
+
return payload.epochs?.length;
|
|
590
|
+
case "bodyComps":
|
|
591
|
+
return payload.bodyComps?.length;
|
|
592
|
+
case "hrv":
|
|
593
|
+
return payload.hrv?.length;
|
|
594
|
+
case "stressDetails":
|
|
595
|
+
return payload.stressDetails?.length;
|
|
596
|
+
case "respiration":
|
|
597
|
+
return payload.respiration?.length;
|
|
598
|
+
case "pulseOx":
|
|
599
|
+
return payload.pulseOx?.length;
|
|
600
|
+
case "bloodPressures":
|
|
601
|
+
return payload.bloodPressures?.length;
|
|
602
|
+
case "userMetrics":
|
|
603
|
+
return payload.userMetrics?.length;
|
|
604
|
+
case "skinTemp":
|
|
605
|
+
return payload.skinTemp?.length;
|
|
606
|
+
case "healthSnapshot":
|
|
607
|
+
return payload.healthSnapshot?.length;
|
|
608
|
+
case "moveiq":
|
|
609
|
+
return payload.moveiq?.length;
|
|
610
|
+
case "mct":
|
|
611
|
+
return payload.menstrualCycleTracking && payload.menstrualCycleTracking.length > 0
|
|
612
|
+
? payload.menstrualCycleTracking.length
|
|
613
|
+
: payload.mct?.length;
|
|
614
|
+
default:
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function isoDateFromTimestamp(timestampMs: number): string {
|
|
620
|
+
return new Date(timestampMs).toISOString().split("T")[0] ?? "";
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Helpers
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Resolve a Garmin userId to the internal userId via the connections table.
|
|
629
|
+
*/
|
|
630
|
+
async function resolveConnection(
|
|
631
|
+
ctx: Pick<ActionCtx, "runQuery">,
|
|
632
|
+
garminUserId: string,
|
|
633
|
+
): Promise<Doc<"connections"> | null> {
|
|
634
|
+
const conn = await ctx.runQuery(internal.connections.getByProviderUser, {
|
|
635
|
+
provider: "garmin",
|
|
636
|
+
providerUserId: garminUserId,
|
|
637
|
+
});
|
|
638
|
+
return (conn as Doc<"connections"> | null) ?? null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Resolve a Garmin userId to a dataSource ID, creating one if needed.
|
|
643
|
+
*/
|
|
644
|
+
async function resolveDataSource(
|
|
645
|
+
ctx: Pick<ActionCtx, "runQuery" | "runMutation">,
|
|
646
|
+
connection: Doc<"connections">,
|
|
647
|
+
deviceName?: string,
|
|
648
|
+
): Promise<Id<"dataSources"> | null> {
|
|
649
|
+
return await ctx.runMutation(api.dataSources.getOrCreate, {
|
|
650
|
+
userId: connection.userId,
|
|
651
|
+
provider: "garmin",
|
|
652
|
+
connectionId: connection._id,
|
|
653
|
+
deviceModel: deviceName,
|
|
654
|
+
source: "garmin",
|
|
655
|
+
});
|
|
656
|
+
}
|