@nativesquare/soma 0.14.0 → 0.14.1
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 +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +3 -2
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +7 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/private.d.ts +18 -85
- package/dist/component/garmin/private.d.ts.map +1 -1
- package/dist/component/garmin/private.js +12 -12
- package/dist/component/garmin/private.js.map +1 -1
- package/dist/component/garmin/public.d.ts +38 -65
- package/dist/component/garmin/public.d.ts.map +1 -1
- package/dist/component/garmin/public.js +132 -10
- package/dist/component/garmin/public.js.map +1 -1
- package/dist/component/garmin/utils.d.ts +48 -0
- package/dist/component/garmin/utils.d.ts.map +1 -1
- package/dist/component/garmin/utils.js +65 -0
- package/dist/component/garmin/utils.js.map +1 -1
- package/dist/component/garmin/webhooks.d.ts +1 -11
- package/dist/component/garmin/webhooks.d.ts.map +1 -1
- package/dist/component/garmin/webhooks.js.map +1 -1
- package/dist/component/public.d.ts +3 -3
- package/dist/component/schema.d.ts +4 -4
- package/dist/component/strava/public.d.ts +4 -15
- package/dist/component/strava/public.d.ts.map +1 -1
- package/dist/component/strava/public.js +4 -3
- package/dist/component/strava/public.js.map +1 -1
- package/dist/component/strava/webhooks.d.ts +3 -10
- package/dist/component/strava/webhooks.d.ts.map +1 -1
- package/dist/component/strava/webhooks.js.map +1 -1
- package/dist/component/validators/daily.d.ts +2 -2
- package/dist/component/validators/shared.d.ts +16 -3
- package/dist/component/validators/shared.d.ts.map +1 -1
- package/dist/component/validators/shared.js +1 -1
- package/dist/component/validators/shared.js.map +1 -1
- package/dist/validators.d.ts +5 -4
- package/dist/validators.d.ts.map +1 -1
- package/dist/validators.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +1 -0
- package/src/client/types.ts +4 -2
- package/src/component/_generated/component.ts +13 -0
- package/src/component/garmin/private.ts +12 -12
- package/src/component/garmin/public.ts +166 -11
- package/src/component/garmin/utils.ts +102 -0
- package/src/component/garmin/webhooks.ts +1 -7
- package/src/component/strava/public.ts +6 -5
- package/src/component/strava/webhooks.ts +9 -11
- package/src/component/validators/shared.ts +47 -4
- package/src/validators.ts +1 -0
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
package/src/client/types.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface SomaGarminConfig {
|
|
|
53
53
|
|
|
54
54
|
// ─── Shared Error & Result Types ───────────────────────────────────────────
|
|
55
55
|
|
|
56
|
+
export type { SomaErrorType } from "../component/validators/shared.js";
|
|
57
|
+
|
|
56
58
|
/**
|
|
57
59
|
* A structured error from a Soma operation.
|
|
58
60
|
*
|
|
@@ -61,8 +63,8 @@ export interface SomaGarminConfig {
|
|
|
61
63
|
* (missing connection, nonexistent document) are thrown as exceptions.
|
|
62
64
|
*/
|
|
63
65
|
export interface SomaError {
|
|
64
|
-
/** Category of the failed item
|
|
65
|
-
type:
|
|
66
|
+
/** Category of the failed item — see {@link SomaErrorType} for valid values. */
|
|
67
|
+
type: import("../component/validators/shared.js").SomaErrorType;
|
|
66
68
|
/** Identifier of the failed item, or `"fetch"` for API-level failures. */
|
|
67
69
|
id: string;
|
|
68
70
|
/** Human-readable error description. */
|
|
@@ -25,6 +25,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
25
25
|
{
|
|
26
26
|
garmin: {
|
|
27
27
|
public: {
|
|
28
|
+
backfillAll: FunctionReference<
|
|
29
|
+
"action",
|
|
30
|
+
"internal",
|
|
31
|
+
{
|
|
32
|
+
clientId: string;
|
|
33
|
+
clientSecret: string;
|
|
34
|
+
endTimeInSeconds?: number;
|
|
35
|
+
startTimeInSeconds?: number;
|
|
36
|
+
userId: string;
|
|
37
|
+
},
|
|
38
|
+
any,
|
|
39
|
+
Name
|
|
40
|
+
>;
|
|
28
41
|
completeGarminOAuth: FunctionReference<
|
|
29
42
|
"action",
|
|
30
43
|
"internal",
|
|
@@ -585,7 +585,7 @@ export const processBodyCompositionsPushPayload = internalAction({
|
|
|
585
585
|
if (!connection) {
|
|
586
586
|
for (const item of userItems) {
|
|
587
587
|
errors.push({
|
|
588
|
-
type: "
|
|
588
|
+
type: "body",
|
|
589
589
|
id: item.summaryId ?? "unknown",
|
|
590
590
|
message: `No Soma connection found for Garmin userId "${garminUserId}"`,
|
|
591
591
|
});
|
|
@@ -596,7 +596,7 @@ export const processBodyCompositionsPushPayload = internalAction({
|
|
|
596
596
|
if (!connection.active) {
|
|
597
597
|
for (const item of userItems) {
|
|
598
598
|
errors.push({
|
|
599
|
-
type: "
|
|
599
|
+
type: "body",
|
|
600
600
|
id: item.summaryId ?? "unknown",
|
|
601
601
|
message: `Garmin connection for userId "${garminUserId}" is inactive`,
|
|
602
602
|
});
|
|
@@ -611,7 +611,7 @@ export const processBodyCompositionsPushPayload = internalAction({
|
|
|
611
611
|
items.push({ connectionId: connection._id, userId: connection.userId, data });
|
|
612
612
|
} catch (err) {
|
|
613
613
|
errors.push({
|
|
614
|
-
type: "
|
|
614
|
+
type: "body",
|
|
615
615
|
id: item.summaryId ?? "unknown",
|
|
616
616
|
message: err instanceof Error ? err.message : String(err),
|
|
617
617
|
});
|
|
@@ -677,7 +677,7 @@ export const processDailiesPushPayload = internalAction({
|
|
|
677
677
|
if (!connection) {
|
|
678
678
|
for (const item of userItems) {
|
|
679
679
|
errors.push({
|
|
680
|
-
type: "
|
|
680
|
+
type: "daily",
|
|
681
681
|
id: item.summaryId ?? "unknown",
|
|
682
682
|
message: `No Soma connection found for Garmin userId "${garminUserId}"`,
|
|
683
683
|
});
|
|
@@ -688,7 +688,7 @@ export const processDailiesPushPayload = internalAction({
|
|
|
688
688
|
if (!connection.active) {
|
|
689
689
|
for (const item of userItems) {
|
|
690
690
|
errors.push({
|
|
691
|
-
type: "
|
|
691
|
+
type: "daily",
|
|
692
692
|
id: item.summaryId ?? "unknown",
|
|
693
693
|
message: `Garmin connection for userId "${garminUserId}" is inactive`,
|
|
694
694
|
});
|
|
@@ -703,7 +703,7 @@ export const processDailiesPushPayload = internalAction({
|
|
|
703
703
|
items.push({ connectionId: connection._id, userId: connection.userId, data });
|
|
704
704
|
} catch (err) {
|
|
705
705
|
errors.push({
|
|
706
|
-
type: "
|
|
706
|
+
type: "daily",
|
|
707
707
|
id: item.summaryId ?? "unknown",
|
|
708
708
|
message: err instanceof Error ? err.message : String(err),
|
|
709
709
|
});
|
|
@@ -861,7 +861,7 @@ export const processHRVSummaryPushPayload = internalAction({
|
|
|
861
861
|
if (!connection) {
|
|
862
862
|
for (const item of userItems) {
|
|
863
863
|
errors.push({
|
|
864
|
-
type: "
|
|
864
|
+
type: "hrv",
|
|
865
865
|
id: item.summaryId ?? "unknown",
|
|
866
866
|
message: `No Soma connection found for Garmin userId "${garminUserId}"`,
|
|
867
867
|
});
|
|
@@ -872,7 +872,7 @@ export const processHRVSummaryPushPayload = internalAction({
|
|
|
872
872
|
if (!connection.active) {
|
|
873
873
|
for (const item of userItems) {
|
|
874
874
|
errors.push({
|
|
875
|
-
type: "
|
|
875
|
+
type: "hrv",
|
|
876
876
|
id: item.summaryId ?? "unknown",
|
|
877
877
|
message: `Garmin connection for userId "${garminUserId}" is inactive`,
|
|
878
878
|
});
|
|
@@ -887,7 +887,7 @@ export const processHRVSummaryPushPayload = internalAction({
|
|
|
887
887
|
items.push({ connectionId: connection._id, userId: connection.userId, data });
|
|
888
888
|
} catch (err) {
|
|
889
889
|
errors.push({
|
|
890
|
-
type: "
|
|
890
|
+
type: "hrv",
|
|
891
891
|
id: item.summaryId ?? "unknown",
|
|
892
892
|
message: err instanceof Error ? err.message : String(err),
|
|
893
893
|
});
|
|
@@ -1594,7 +1594,7 @@ export const processMenstrualCycleTrackingPushPayload = internalAction({
|
|
|
1594
1594
|
if (!connection) {
|
|
1595
1595
|
for (const item of userItems) {
|
|
1596
1596
|
errors.push({
|
|
1597
|
-
type: "
|
|
1597
|
+
type: "menstruation",
|
|
1598
1598
|
id: item.summaryId ?? "unknown",
|
|
1599
1599
|
message: `No Soma connection found for Garmin userId "${garminUserId}"`,
|
|
1600
1600
|
});
|
|
@@ -1605,7 +1605,7 @@ export const processMenstrualCycleTrackingPushPayload = internalAction({
|
|
|
1605
1605
|
if (!connection.active) {
|
|
1606
1606
|
for (const item of userItems) {
|
|
1607
1607
|
errors.push({
|
|
1608
|
-
type: "
|
|
1608
|
+
type: "menstruation",
|
|
1609
1609
|
id: item.summaryId ?? "unknown",
|
|
1610
1610
|
message: `Garmin connection for userId "${garminUserId}" is inactive`,
|
|
1611
1611
|
});
|
|
@@ -1619,7 +1619,7 @@ export const processMenstrualCycleTrackingPushPayload = internalAction({
|
|
|
1619
1619
|
items.push({ connectionId: connection._id, userId: connection.userId, data });
|
|
1620
1620
|
} catch (err) {
|
|
1621
1621
|
errors.push({
|
|
1622
|
-
type: "
|
|
1622
|
+
type: "menstruation",
|
|
1623
1623
|
id: item.summaryId ?? "unknown",
|
|
1624
1624
|
message: err instanceof Error ? err.message : String(err),
|
|
1625
1625
|
});
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
createWellnessClient,
|
|
19
19
|
createTrainingClient,
|
|
20
20
|
} from "./client.js";
|
|
21
|
-
import { buildTimeRangeQuery } from "./utils.js";
|
|
21
|
+
import { buildTimeRangeQuery, buildChunkedTimeRangeQueries, buildBackfillQuery, buildChunkedBackfillQueries } from "./utils.js";
|
|
22
22
|
import {
|
|
23
23
|
createWorkoutV2 as sdkCreateWorkoutV2,
|
|
24
24
|
createWorkoutSchedule as sdkCreateWorkoutSchedule,
|
|
@@ -42,6 +42,19 @@ import {
|
|
|
42
42
|
getStressDetails,
|
|
43
43
|
getPulseox,
|
|
44
44
|
getRespiration,
|
|
45
|
+
getBackfillDailies,
|
|
46
|
+
getBackfillSleeps,
|
|
47
|
+
getBackfillBodycomps,
|
|
48
|
+
getBackfillMct,
|
|
49
|
+
getBackfillBloodPressures,
|
|
50
|
+
getBackfillSkinTemp,
|
|
51
|
+
getBackfillUserMetrics,
|
|
52
|
+
getBackfillHrv,
|
|
53
|
+
getBackfillStressDetails,
|
|
54
|
+
getBackfillPulseox,
|
|
55
|
+
getBackfillRespirationEpoch,
|
|
56
|
+
getBackfillActivities,
|
|
57
|
+
getBackfillActivityDetails,
|
|
45
58
|
} from "./types/wellnessApi/sdk.gen";
|
|
46
59
|
import { transformActivity } from "./transform/activity.js";
|
|
47
60
|
import { transformDailies } from "./transform/dailies.js";
|
|
@@ -57,7 +70,7 @@ import { transformPulseOx } from "./transform/pulseOx.js";
|
|
|
57
70
|
import { transformRespiration } from "./transform/respiration.js";
|
|
58
71
|
import { transformPlannedWorkoutToGarmin } from "./transform/plannedWorkout.js";
|
|
59
72
|
import { api, internal } from "../_generated/api";
|
|
60
|
-
import type { SomaError } from "../validators/shared.js";
|
|
73
|
+
import type { SomaError, SomaErrorType } from "../validators/shared.js";
|
|
61
74
|
|
|
62
75
|
// ─── OAuth ──────────────────────────────────────────────────────────────────
|
|
63
76
|
|
|
@@ -187,8 +200,8 @@ export const disconnectGarmin = action({
|
|
|
187
200
|
try {
|
|
188
201
|
const wellnessClient = createWellnessClient(tokenDoc.accessToken);
|
|
189
202
|
await sdkDereg({ client: wellnessClient });
|
|
190
|
-
} catch {
|
|
191
|
-
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.warn("[garmin:disconnect] Best-effort deregistration failed:", err instanceof Error ? err.message : err);
|
|
192
205
|
}
|
|
193
206
|
}
|
|
194
207
|
|
|
@@ -507,11 +520,11 @@ export const pullSkinTemperature = action({
|
|
|
507
520
|
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
508
521
|
synced.skinTemp++;
|
|
509
522
|
} catch (err) {
|
|
510
|
-
errors.push({ type: "
|
|
523
|
+
errors.push({ type: "skinTemperature", id: skin.summaryId ?? skin.calendarDate ?? "unknown", message: err instanceof Error ? err.message : String(err) });
|
|
511
524
|
}
|
|
512
525
|
}
|
|
513
526
|
} catch (err) {
|
|
514
|
-
errors.push({ type: "
|
|
527
|
+
errors.push({ type: "skinTemperature", id: "fetch", message: err instanceof Error ? err.message : String(err) });
|
|
515
528
|
}
|
|
516
529
|
|
|
517
530
|
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
@@ -740,14 +753,15 @@ export const pullAll = action({
|
|
|
740
753
|
startTimeInSeconds: args.startTimeInSeconds,
|
|
741
754
|
endTimeInSeconds: args.endTimeInSeconds,
|
|
742
755
|
};
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
{ ref: api.garmin.public.
|
|
756
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
757
|
+
const pullFns: Array<{ ref: any; name: SomaErrorType }> = [
|
|
758
|
+
{ ref: api.garmin.public.pullActivities, name: "activity" },
|
|
759
|
+
{ ref: api.garmin.public.pullDailies, name: "daily" },
|
|
746
760
|
{ ref: api.garmin.public.pullSleep, name: "sleep" },
|
|
747
761
|
{ ref: api.garmin.public.pullBody, name: "body" },
|
|
748
762
|
{ ref: api.garmin.public.pullMenstruation, name: "menstruation" },
|
|
749
|
-
{ ref: api.garmin.public.pullBloodPressures, name: "
|
|
750
|
-
{ ref: api.garmin.public.pullSkinTemperature, name: "
|
|
763
|
+
{ ref: api.garmin.public.pullBloodPressures, name: "bloodPressure" },
|
|
764
|
+
{ ref: api.garmin.public.pullSkinTemperature, name: "skinTemperature" },
|
|
751
765
|
{ ref: api.garmin.public.pullUserMetrics, name: "userMetrics" },
|
|
752
766
|
{ ref: api.garmin.public.pullHRV, name: "hrv" },
|
|
753
767
|
{ ref: api.garmin.public.pullStressDetails, name: "stressDetails" },
|
|
@@ -774,6 +788,147 @@ export const pullAll = action({
|
|
|
774
788
|
});
|
|
775
789
|
|
|
776
790
|
|
|
791
|
+
// ─── Backfill ─────────────────────────────────────────────────────────────
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Request historical data from Garmin's backfill API for all summary types.
|
|
795
|
+
*
|
|
796
|
+
* Unlike pull functions (which query by upload time with a 24h max window),
|
|
797
|
+
* backfill requests data by the time it was *recorded* on the device.
|
|
798
|
+
* Garmin processes backfill asynchronously (HTTP 202) and delivers data
|
|
799
|
+
* via push/ping webhooks — no data is returned synchronously.
|
|
800
|
+
*
|
|
801
|
+
* Health endpoints accept up to 90 days per request.
|
|
802
|
+
* Activity endpoints accept up to 30 days per request (auto-chunked).
|
|
803
|
+
*
|
|
804
|
+
* Defaults to the last 90 days when no time range is provided.
|
|
805
|
+
*/
|
|
806
|
+
export const backfillAll = action({
|
|
807
|
+
args: {
|
|
808
|
+
userId: v.string(),
|
|
809
|
+
clientId: v.string(),
|
|
810
|
+
clientSecret: v.string(),
|
|
811
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
812
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
813
|
+
},
|
|
814
|
+
handler: async (ctx, args) => {
|
|
815
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
816
|
+
internal.private.resolveConnectionAndAccessToken,
|
|
817
|
+
{
|
|
818
|
+
userId: args.userId,
|
|
819
|
+
provider: "GARMIN",
|
|
820
|
+
clientId: args.clientId,
|
|
821
|
+
clientSecret: args.clientSecret,
|
|
822
|
+
},
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
826
|
+
const timeInput = {
|
|
827
|
+
startTimeInSeconds: args.startTimeInSeconds,
|
|
828
|
+
endTimeInSeconds: args.endTimeInSeconds,
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
// Health endpoints: up to 90 days, single request each
|
|
832
|
+
const healthQuery = buildBackfillQuery(timeInput);
|
|
833
|
+
|
|
834
|
+
// Activity endpoints: max 30 days per request, chunked
|
|
835
|
+
const activityChunks = buildChunkedBackfillQueries(timeInput);
|
|
836
|
+
|
|
837
|
+
const requested: string[] = [];
|
|
838
|
+
const errors: SomaError[] = [];
|
|
839
|
+
|
|
840
|
+
// ── Health backfills (90-day window) ──
|
|
841
|
+
const healthBackfills: Array<{
|
|
842
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
843
|
+
fn: (opts: any) => Promise<any>;
|
|
844
|
+
name: string;
|
|
845
|
+
}> = [
|
|
846
|
+
{ fn: getBackfillDailies, name: "dailies" },
|
|
847
|
+
{ fn: getBackfillSleeps, name: "sleeps" },
|
|
848
|
+
{ fn: getBackfillBodycomps, name: "bodyComps" },
|
|
849
|
+
{ fn: getBackfillMct, name: "mct" },
|
|
850
|
+
{ fn: getBackfillBloodPressures, name: "bloodPressures" },
|
|
851
|
+
{ fn: getBackfillSkinTemp, name: "skinTemp" },
|
|
852
|
+
{ fn: getBackfillUserMetrics, name: "userMetrics" },
|
|
853
|
+
{ fn: getBackfillHrv, name: "hrv" },
|
|
854
|
+
{ fn: getBackfillStressDetails, name: "stressDetails" },
|
|
855
|
+
{ fn: getBackfillPulseox, name: "pulseOx" },
|
|
856
|
+
{ fn: getBackfillRespirationEpoch, name: "respiration" },
|
|
857
|
+
];
|
|
858
|
+
|
|
859
|
+
for (const { fn, name } of healthBackfills) {
|
|
860
|
+
try {
|
|
861
|
+
const { error } = await fn({
|
|
862
|
+
client: wellnessClient,
|
|
863
|
+
query: healthQuery,
|
|
864
|
+
});
|
|
865
|
+
if (error) {
|
|
866
|
+
errors.push({
|
|
867
|
+
type: name as SomaErrorType,
|
|
868
|
+
id: "backfill",
|
|
869
|
+
message: JSON.stringify(error),
|
|
870
|
+
});
|
|
871
|
+
} else {
|
|
872
|
+
requested.push(name);
|
|
873
|
+
}
|
|
874
|
+
} catch (err) {
|
|
875
|
+
errors.push({
|
|
876
|
+
type: name as SomaErrorType,
|
|
877
|
+
id: "backfill",
|
|
878
|
+
message: err instanceof Error ? err.message : String(err),
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ── Activity backfills (30-day chunked windows) ──
|
|
884
|
+
const activityBackfills: Array<{
|
|
885
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
886
|
+
fn: (opts: any) => Promise<any>;
|
|
887
|
+
name: string;
|
|
888
|
+
}> = [
|
|
889
|
+
{ fn: getBackfillActivities, name: "activities" },
|
|
890
|
+
{ fn: getBackfillActivityDetails, name: "activityDetails" },
|
|
891
|
+
];
|
|
892
|
+
|
|
893
|
+
for (const { fn, name } of activityBackfills) {
|
|
894
|
+
let chunkErrors = 0;
|
|
895
|
+
for (const chunk of activityChunks) {
|
|
896
|
+
try {
|
|
897
|
+
const { error } = await fn({
|
|
898
|
+
client: wellnessClient,
|
|
899
|
+
query: chunk,
|
|
900
|
+
});
|
|
901
|
+
if (error) {
|
|
902
|
+
chunkErrors++;
|
|
903
|
+
errors.push({
|
|
904
|
+
type: name as SomaErrorType,
|
|
905
|
+
id: "backfill",
|
|
906
|
+
message: JSON.stringify(error),
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
} catch (err) {
|
|
910
|
+
chunkErrors++;
|
|
911
|
+
errors.push({
|
|
912
|
+
type: name as SomaErrorType,
|
|
913
|
+
id: "backfill",
|
|
914
|
+
message: err instanceof Error ? err.message : String(err),
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (chunkErrors < activityChunks.length) {
|
|
919
|
+
requested.push(name);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
await ctx.runMutation(api.public.updateConnection, {
|
|
924
|
+
connectionId,
|
|
925
|
+
lastDataUpdate: new Date().toISOString(),
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
return { data: { requested }, errors };
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
|
|
777
932
|
// ─── Push ───────────────────────────────────────────────────────────────────
|
|
778
933
|
|
|
779
934
|
export const pushWorkout = action({
|
|
@@ -39,4 +39,106 @@ export function buildTimeRangeQuery(
|
|
|
39
39
|
uploadEndTimeInSeconds: String(uploadEndTimeInSeconds),
|
|
40
40
|
token: accessToken,
|
|
41
41
|
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build an array of time-range query objects, each spanning at most
|
|
46
|
+
* `maxRangeSeconds`.
|
|
47
|
+
*
|
|
48
|
+
* Garmin's intraday endpoints (respiration, stress, pulse-ox, epochs, HRV)
|
|
49
|
+
* enforce a maximum query window of 86 400 s (24 h). This helper splits the
|
|
50
|
+
* full requested range into consecutive, non-overlapping chunks so the caller
|
|
51
|
+
* can issue one API call per chunk and merge the results.
|
|
52
|
+
*/
|
|
53
|
+
export function buildChunkedTimeRangeQueries(
|
|
54
|
+
input: { startTimeInSeconds?: number; endTimeInSeconds?: number },
|
|
55
|
+
accessToken: string,
|
|
56
|
+
maxRangeSeconds: number,
|
|
57
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
58
|
+
defaultSyncDays = 30,
|
|
59
|
+
) {
|
|
60
|
+
const start =
|
|
61
|
+
input.startTimeInSeconds ?? nowSeconds - defaultSyncDays * 86400;
|
|
62
|
+
const end = input.endTimeInSeconds ?? nowSeconds;
|
|
63
|
+
|
|
64
|
+
const chunks: Array<{
|
|
65
|
+
uploadStartTimeInSeconds: string;
|
|
66
|
+
uploadEndTimeInSeconds: string;
|
|
67
|
+
token: string;
|
|
68
|
+
}> = [];
|
|
69
|
+
|
|
70
|
+
for (let chunkStart = start; chunkStart < end; chunkStart += maxRangeSeconds) {
|
|
71
|
+
const chunkEnd = Math.min(chunkStart + maxRangeSeconds, end);
|
|
72
|
+
chunks.push({
|
|
73
|
+
uploadStartTimeInSeconds: String(chunkStart),
|
|
74
|
+
uploadEndTimeInSeconds: String(chunkEnd),
|
|
75
|
+
token: accessToken,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return chunks;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Backfill Query Helpers ────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const BACKFILL_MAX_HEALTH_DAYS = 90;
|
|
85
|
+
const BACKFILL_MAX_ACTIVITY_DAYS = 30;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a backfill query object for Garmin's `/rest/backfill/*` endpoints.
|
|
89
|
+
*
|
|
90
|
+
* Backfill uses `summaryStartTimeInSeconds` / `summaryEndTimeInSeconds`
|
|
91
|
+
* (based on when data was *recorded*, not uploaded) and does NOT take a
|
|
92
|
+
* `token` query param — auth is via Bearer header only.
|
|
93
|
+
*
|
|
94
|
+
* Health endpoints accept up to 90 days per request.
|
|
95
|
+
* Activity endpoints accept up to 30 days per request.
|
|
96
|
+
*/
|
|
97
|
+
export function buildBackfillQuery(
|
|
98
|
+
input: { startTimeInSeconds?: number; endTimeInSeconds?: number },
|
|
99
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
100
|
+
defaultDays = BACKFILL_MAX_HEALTH_DAYS,
|
|
101
|
+
) {
|
|
102
|
+
const summaryStartTimeInSeconds =
|
|
103
|
+
input.startTimeInSeconds ?? nowSeconds - defaultDays * 86400;
|
|
104
|
+
const summaryEndTimeInSeconds = input.endTimeInSeconds ?? nowSeconds;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
summaryStartTimeInSeconds: String(summaryStartTimeInSeconds),
|
|
108
|
+
summaryEndTimeInSeconds: String(summaryEndTimeInSeconds),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build an array of backfill query objects, each spanning at most
|
|
114
|
+
* `maxDays` days.
|
|
115
|
+
*
|
|
116
|
+
* Activity backfill endpoints enforce a 30-day max per request.
|
|
117
|
+
* This splits a wider range into consecutive, non-overlapping chunks.
|
|
118
|
+
*/
|
|
119
|
+
export function buildChunkedBackfillQueries(
|
|
120
|
+
input: { startTimeInSeconds?: number; endTimeInSeconds?: number },
|
|
121
|
+
maxDays = BACKFILL_MAX_ACTIVITY_DAYS,
|
|
122
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
123
|
+
defaultDays = BACKFILL_MAX_HEALTH_DAYS,
|
|
124
|
+
) {
|
|
125
|
+
const start =
|
|
126
|
+
input.startTimeInSeconds ?? nowSeconds - defaultDays * 86400;
|
|
127
|
+
const end = input.endTimeInSeconds ?? nowSeconds;
|
|
128
|
+
|
|
129
|
+
const maxRangeSeconds = maxDays * 86400;
|
|
130
|
+
const chunks: Array<{
|
|
131
|
+
summaryStartTimeInSeconds: string;
|
|
132
|
+
summaryEndTimeInSeconds: string;
|
|
133
|
+
}> = [];
|
|
134
|
+
|
|
135
|
+
for (let chunkStart = start; chunkStart < end; chunkStart += maxRangeSeconds) {
|
|
136
|
+
const chunkEnd = Math.min(chunkStart + maxRangeSeconds, end);
|
|
137
|
+
chunks.push({
|
|
138
|
+
summaryStartTimeInSeconds: String(chunkStart),
|
|
139
|
+
summaryEndTimeInSeconds: String(chunkEnd),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return chunks;
|
|
42
144
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { v } from "convex/values";
|
|
7
7
|
import { action, type ActionCtx } from "../_generated/server";
|
|
8
8
|
import { api, internal } from "../_generated/api";
|
|
9
|
-
import type { SomaError } from "../validators/shared.js";
|
|
9
|
+
import type { SomaError, WebhookResult } from "../validators/shared.js";
|
|
10
10
|
import {
|
|
11
11
|
garminSkinTemperaturePingPayloadSchema,
|
|
12
12
|
garminSkinTemperaturePushPayloadSchema,
|
|
@@ -104,12 +104,6 @@ function isWebhookPushMode(payload: unknown): boolean {
|
|
|
104
104
|
return !("callbackURL" in firstItem);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** Shape returned by webhook handler actions (both internal processors and public handlers). */
|
|
108
|
-
type WebhookResult = {
|
|
109
|
-
errors: SomaError[];
|
|
110
|
-
items: Array<{ connectionId: string; userId: string; data: Record<string, unknown> }>;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
107
|
/**
|
|
114
108
|
* Ingest transformed items from a private handler and update connections.
|
|
115
109
|
* Shared orchestration logic for all push-mode webhook handlers.
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "./auth.js";
|
|
22
22
|
import { transformActivity } from "./transform/activity.js";
|
|
23
23
|
import { transformAthlete } from "./transform/athlete.js";
|
|
24
|
-
import type { SomaError } from "../validators/shared.js";
|
|
24
|
+
import type { SomaError, SomaErrorType } from "../validators/shared.js";
|
|
25
25
|
|
|
26
26
|
// ─── OAuth ──────────────────────────────────────────────────────────────────
|
|
27
27
|
|
|
@@ -153,8 +153,8 @@ export const disconnectStrava = action({
|
|
|
153
153
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
154
154
|
body: `access_token=${tokenDoc.accessToken}`,
|
|
155
155
|
});
|
|
156
|
-
} catch {
|
|
157
|
-
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.warn("[strava:disconnect] Best-effort deauthorization failed:", err instanceof Error ? err.message : err);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
// 3. Delete stored tokens
|
|
@@ -303,9 +303,10 @@ export const pullAll = action({
|
|
|
303
303
|
clientId: args.clientId,
|
|
304
304
|
clientSecret: args.clientSecret,
|
|
305
305
|
};
|
|
306
|
-
|
|
306
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
307
|
+
const pullFns: Array<{ ref: any; name: SomaErrorType; args: Record<string, unknown> }> = [
|
|
307
308
|
{ ref: api.strava.public.pullAthlete, name: "athlete", args: sharedArgs },
|
|
308
|
-
{ ref: api.strava.public.pullActivities, name: "
|
|
309
|
+
{ ref: api.strava.public.pullActivities, name: "activity", args: { ...sharedArgs, after: args.after, before: args.before } },
|
|
309
310
|
];
|
|
310
311
|
const synced: Record<string, number> = {};
|
|
311
312
|
const errors: SomaError[] = [];
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./types/stravaApi/sdk.gen.js";
|
|
18
18
|
import { transformActivity } from "./transform/activity.js";
|
|
19
19
|
import { transformAthlete } from "./transform/athlete.js";
|
|
20
|
-
import type { SomaError } from "../validators/shared.js";
|
|
20
|
+
import type { SomaError, SomaErrorType, WebhookResult } from "../validators/shared.js";
|
|
21
21
|
|
|
22
22
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
|
23
23
|
|
|
@@ -41,10 +41,8 @@ const VALID_EVENT_NAMES = new Set([
|
|
|
41
41
|
"athlete-deauthorize",
|
|
42
42
|
]);
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
items: Array<{ connectionId: string; userId: string; data: Record<string, unknown> | null }>;
|
|
47
|
-
};
|
|
44
|
+
// Strava webhooks use WebhookResult with nullable data (for delete/deauthorize events).
|
|
45
|
+
type StravaWebhookResult = WebhookResult<Record<string, unknown> | null>;
|
|
48
46
|
|
|
49
47
|
// ─── Handler ────────────────────────────────────────────────────────────────
|
|
50
48
|
|
|
@@ -55,7 +53,7 @@ export const handleStravaWebhook = action({
|
|
|
55
53
|
clientSecret: v.string(),
|
|
56
54
|
autoIngest: v.optional(v.boolean()),
|
|
57
55
|
},
|
|
58
|
-
handler: async (ctx, args): Promise<
|
|
56
|
+
handler: async (ctx, args): Promise<StravaWebhookResult> => {
|
|
59
57
|
const payload = stravaWebhookPayloadSchema.parse(args.payload);
|
|
60
58
|
const { clientId, clientSecret } = args;
|
|
61
59
|
const shouldIngest = args.autoIngest !== false;
|
|
@@ -74,7 +72,7 @@ export const handleStravaWebhook = action({
|
|
|
74
72
|
if (!connection) {
|
|
75
73
|
return {
|
|
76
74
|
errors: [{
|
|
77
|
-
type: payload.object_type,
|
|
75
|
+
type: payload.object_type as SomaErrorType,
|
|
78
76
|
id: String(payload.object_id),
|
|
79
77
|
message: `No Soma connection found for Strava owner_id "${payload.owner_id}". ` +
|
|
80
78
|
"The user may need to reconnect to populate the provider user ID.",
|
|
@@ -87,7 +85,7 @@ export const handleStravaWebhook = action({
|
|
|
87
85
|
if (!connection.active && eventName !== "athlete-deauthorize") {
|
|
88
86
|
return {
|
|
89
87
|
errors: [{
|
|
90
|
-
type: payload.object_type,
|
|
88
|
+
type: payload.object_type as SomaErrorType,
|
|
91
89
|
id: String(payload.object_id),
|
|
92
90
|
message: `Strava connection for owner_id "${payload.owner_id}" is inactive`,
|
|
93
91
|
}],
|
|
@@ -141,7 +139,7 @@ async function handleActivityCreateOrUpdate(
|
|
|
141
139
|
clientSecret: string;
|
|
142
140
|
shouldIngest: boolean;
|
|
143
141
|
},
|
|
144
|
-
): Promise<
|
|
142
|
+
): Promise<StravaWebhookResult> {
|
|
145
143
|
const errors: SomaError[] = [];
|
|
146
144
|
|
|
147
145
|
let accessToken: string;
|
|
@@ -219,7 +217,7 @@ async function handleAthleteUpdate(
|
|
|
219
217
|
clientSecret: string;
|
|
220
218
|
shouldIngest: boolean;
|
|
221
219
|
},
|
|
222
|
-
): Promise<
|
|
220
|
+
): Promise<StravaWebhookResult> {
|
|
223
221
|
let accessToken: string;
|
|
224
222
|
try {
|
|
225
223
|
const resolved = await ctx.runAction(
|
|
@@ -283,7 +281,7 @@ async function handleAthleteDeauthorize(
|
|
|
283
281
|
userId: string;
|
|
284
282
|
shouldIngest: boolean;
|
|
285
283
|
},
|
|
286
|
-
): Promise<
|
|
284
|
+
): Promise<StravaWebhookResult> {
|
|
287
285
|
if (args.shouldIngest) {
|
|
288
286
|
try {
|
|
289
287
|
await ctx.runMutation(internal.private.deleteTokens, {
|