@nativesquare/soma 0.10.2 → 0.12.0
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/garmin.d.ts +287 -0
- package/dist/client/garmin.d.ts.map +1 -0
- package/dist/client/garmin.js +345 -0
- package/dist/client/garmin.js.map +1 -0
- package/dist/client/index.d.ts +27 -467
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +33 -385
- package/dist/client/index.js.map +1 -1
- package/dist/client/strava.d.ts +92 -0
- package/dist/client/strava.d.ts.map +1 -0
- package/dist/client/strava.js +96 -0
- package/dist/client/strava.js.map +1 -0
- package/dist/client/types.d.ts +165 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +17 -12
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/public.d.ts +18 -84
- package/dist/component/garmin/public.d.ts.map +1 -1
- package/dist/component/garmin/public.js +147 -539
- package/dist/component/garmin/public.js.map +1 -1
- package/package.json +1 -1
- package/src/client/garmin.ts +487 -0
- package/src/client/index.ts +69 -711
- package/src/client/strava.ts +108 -0
- package/src/client/types.ts +215 -18
- package/src/component/_generated/component.ts +29 -18
- package/src/component/garmin/public.ts +1049 -1406
- package/src/component/garmin/webhooks.ts +857 -857
|
@@ -7,8 +7,8 @@ import { action } from "../_generated/server";
|
|
|
7
7
|
import { generateState } from "../utils.js";
|
|
8
8
|
import { generateCodeVerifier, generateCodeChallenge, buildAuthUrl, exchangeCode, refreshToken, } from "./auth.js";
|
|
9
9
|
import { createWellnessClient, createTrainingClient, } from "./client.js";
|
|
10
|
-
import { buildTimeRangeQuery
|
|
11
|
-
import { createWorkoutV2 as sdkCreateWorkoutV2, createWorkoutSchedule as sdkCreateWorkoutSchedule, } from "./types/trainingApiWorkouts/sdk.gen";
|
|
10
|
+
import { buildTimeRangeQuery } from "./utils.js";
|
|
11
|
+
import { createWorkoutV2 as sdkCreateWorkoutV2, createWorkoutSchedule as sdkCreateWorkoutSchedule, updateWorkoutV2 as sdkUpdateWorkoutV2, updateWorkoutSchedule as sdkUpdateWorkoutSchedule, deleteWorkoutV2 as sdkDeleteWorkoutV2, deleteWorkoutSchedule as sdkDeleteWorkoutSchedule, } from "./types/trainingApiWorkouts/sdk.gen";
|
|
12
12
|
import { userId as sdkUserId, dereg as sdkDereg, getActivities, getDailies, getSleeps, getBodyComps, getMct, getBloodPressures, getSkinTemp, getUserMetrics, getHrv, getStressDetails, getPulseox, getRespiration, } from "./types/wellnessApi/sdk.gen";
|
|
13
13
|
import { transformActivity } from "./transform/activity.js";
|
|
14
14
|
import { transformDailies } from "./transform/dailies.js";
|
|
@@ -24,18 +24,7 @@ import { transformPulseOx } from "./transform/pulseOx.js";
|
|
|
24
24
|
import { transformRespiration } from "./transform/respiration.js";
|
|
25
25
|
import { transformPlannedWorkoutToGarmin } from "./transform/plannedWorkout.js";
|
|
26
26
|
import { api, internal } from "../_generated/api";
|
|
27
|
-
// Default sync window: last 30 days
|
|
28
|
-
const DEFAULT_SYNC_DAYS = 30;
|
|
29
|
-
// Refresh buffer: refresh tokens 10 minutes before expiry
|
|
30
|
-
const REFRESH_BUFFER_SECONDS = 600;
|
|
31
27
|
// ─── OAuth ──────────────────────────────────────────────────────────────────
|
|
32
|
-
/**
|
|
33
|
-
* Generate a Garmin OAuth 2.0 authorization URL with PKCE.
|
|
34
|
-
*
|
|
35
|
-
* The PKCE code verifier and state are stored in the component's
|
|
36
|
-
* `pendingOAuth` table so that `completeGarminOAuth` can look them up
|
|
37
|
-
* automatically when the callback fires via `registerRoutes`.
|
|
38
|
-
*/
|
|
39
28
|
export const getGarminAuthUrl = action({
|
|
40
29
|
args: {
|
|
41
30
|
clientId: v.string(),
|
|
@@ -61,17 +50,6 @@ export const getGarminAuthUrl = action({
|
|
|
61
50
|
return { authUrl, state, codeVerifier };
|
|
62
51
|
},
|
|
63
52
|
});
|
|
64
|
-
/**
|
|
65
|
-
* Complete a Garmin OAuth 2.0 flow using stored pending state.
|
|
66
|
-
*
|
|
67
|
-
* Called internally by `registerRoutes` — the callback handler calls
|
|
68
|
-
* this with the `code` and `state` from the redirect. The action looks
|
|
69
|
-
* up the pending state (codeVerifier, userId) stored during
|
|
70
|
-
* `getGarminAuthUrl`, exchanges for tokens, creates the connection,
|
|
71
|
-
* stores tokens, and cleans up the pending entry.
|
|
72
|
-
*
|
|
73
|
-
* The host app is responsible for calling `syncGarmin` afterwards.
|
|
74
|
-
*/
|
|
75
53
|
export const completeGarminOAuth = action({
|
|
76
54
|
args: {
|
|
77
55
|
code: v.string(),
|
|
@@ -129,12 +107,6 @@ export const completeGarminOAuth = action({
|
|
|
129
107
|
};
|
|
130
108
|
},
|
|
131
109
|
});
|
|
132
|
-
/**
|
|
133
|
-
* Disconnect a user from Garmin.
|
|
134
|
-
*
|
|
135
|
-
* Deregisters the user via the Garmin API (best-effort), deletes stored
|
|
136
|
-
* tokens, and sets the connection to inactive.
|
|
137
|
-
*/
|
|
138
110
|
export const disconnectGarmin = action({
|
|
139
111
|
args: {
|
|
140
112
|
userId: v.string(),
|
|
@@ -689,572 +661,208 @@ export const pullAll = action({
|
|
|
689
661
|
return { synced, errors };
|
|
690
662
|
},
|
|
691
663
|
});
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
*
|
|
695
|
-
* Looks up the stored tokens, refreshes if expired, and syncs all data
|
|
696
|
-
* types for the specified time range (defaults to last 30 days).
|
|
697
|
-
*/
|
|
698
|
-
export const syncGarmin = action({
|
|
664
|
+
// ─── Push ───────────────────────────────────────────────────────────────────
|
|
665
|
+
export const pushWorkout = action({
|
|
699
666
|
args: {
|
|
700
667
|
userId: v.string(),
|
|
701
668
|
clientId: v.string(),
|
|
702
669
|
clientSecret: v.string(),
|
|
703
|
-
|
|
704
|
-
|
|
670
|
+
plannedWorkoutId: v.string(),
|
|
671
|
+
workoutProvider: v.optional(v.string()),
|
|
705
672
|
},
|
|
706
673
|
handler: async (ctx, args) => {
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
if (!connection.active) {
|
|
713
|
-
throw new Error(`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`);
|
|
714
|
-
}
|
|
715
|
-
const connectionId = connection._id;
|
|
716
|
-
const tokenDoc = await ctx.runQuery(internal.garmin.private.getTokens, {
|
|
717
|
-
connectionId,
|
|
718
|
-
});
|
|
719
|
-
if (!tokenDoc) {
|
|
720
|
-
throw new Error("No Garmin tokens found for this connection. " +
|
|
721
|
-
"The connection may have been created before token storage was available.");
|
|
674
|
+
const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
|
|
675
|
+
const plannedWorkout = await ctx.runQuery(api.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
|
|
676
|
+
if (!plannedWorkout) {
|
|
677
|
+
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
722
678
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
const _refreshed = await ctx.runMutation(internal.garmin.private.storeTokens, {
|
|
737
|
-
connectionId,
|
|
738
|
-
accessToken: refreshed.access_token,
|
|
739
|
-
refreshToken: refreshed.refresh_token,
|
|
740
|
-
expiresAt: newExpiresAt,
|
|
679
|
+
const providerName = args.workoutProvider ?? "Soma";
|
|
680
|
+
const garminWorkout = transformPlannedWorkoutToGarmin(plannedWorkout, providerName);
|
|
681
|
+
const trainingClient = createTrainingClient(accessToken);
|
|
682
|
+
const existingWorkoutId = plannedWorkout.metadata?.provider_workout_id;
|
|
683
|
+
let workoutId;
|
|
684
|
+
if (existingWorkoutId) {
|
|
685
|
+
// Update existing workout on Garmin
|
|
686
|
+
const numericId = Number(existingWorkoutId);
|
|
687
|
+
garminWorkout.workoutId = numericId;
|
|
688
|
+
const { error: updateError } = await sdkUpdateWorkoutV2({
|
|
689
|
+
client: trainingClient,
|
|
690
|
+
body: garminWorkout,
|
|
691
|
+
path: { workoutId: numericId },
|
|
741
692
|
});
|
|
693
|
+
if (updateError) {
|
|
694
|
+
throw new Error(`Garmin API error updating workout: ${JSON.stringify(updateError)}`);
|
|
695
|
+
}
|
|
696
|
+
workoutId = numericId;
|
|
742
697
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
providerUserId: garminUserId,
|
|
752
|
-
});
|
|
698
|
+
else {
|
|
699
|
+
// Create new workout on Garmin
|
|
700
|
+
const { data: created, error: createError } = await sdkCreateWorkoutV2({
|
|
701
|
+
client: trainingClient,
|
|
702
|
+
body: garminWorkout,
|
|
703
|
+
});
|
|
704
|
+
if (createError || !created) {
|
|
705
|
+
throw new Error(`Garmin API error creating workout: ${createError ? JSON.stringify(createError) : "No data"}`);
|
|
753
706
|
}
|
|
707
|
+
if (!created.workoutId) {
|
|
708
|
+
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
709
|
+
}
|
|
710
|
+
workoutId = created.workoutId;
|
|
754
711
|
}
|
|
755
|
-
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
|
|
765
|
-
uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
|
|
766
|
-
});
|
|
767
|
-
const _updated = await ctx.runMutation(api.public.updateConnection, {
|
|
768
|
-
connectionId,
|
|
769
|
-
lastDataUpdate: new Date().toISOString(),
|
|
712
|
+
// Persist the Garmin workout ID back on the planned workout
|
|
713
|
+
const _ingested = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
714
|
+
...plannedWorkout,
|
|
715
|
+
_id: undefined,
|
|
716
|
+
_creationTime: undefined,
|
|
717
|
+
metadata: {
|
|
718
|
+
...plannedWorkout.metadata,
|
|
719
|
+
provider_workout_id: String(workoutId),
|
|
720
|
+
},
|
|
770
721
|
});
|
|
771
|
-
return
|
|
722
|
+
return { garminWorkoutId: workoutId };
|
|
772
723
|
},
|
|
773
724
|
});
|
|
774
|
-
|
|
775
|
-
* Fetch and ingest all Garmin wellness data types for a time range.
|
|
776
|
-
*
|
|
777
|
-
* Called by syncGarmin after obtaining a valid access token.
|
|
778
|
-
* after obtaining a valid access token.
|
|
779
|
-
*/
|
|
780
|
-
export const syncAllTypes = action({
|
|
725
|
+
export const pushSchedule = action({
|
|
781
726
|
args: {
|
|
782
|
-
accessToken: v.string(),
|
|
783
|
-
connectionId: v.id("connections"),
|
|
784
727
|
userId: v.string(),
|
|
785
|
-
|
|
786
|
-
|
|
728
|
+
clientId: v.string(),
|
|
729
|
+
clientSecret: v.string(),
|
|
730
|
+
plannedWorkoutId: v.string(),
|
|
731
|
+
date: v.optional(v.string()),
|
|
787
732
|
},
|
|
788
733
|
handler: async (ctx, args) => {
|
|
789
|
-
const {
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
};
|
|
794
|
-
const wellnessClient = createWellnessClient(accessToken);
|
|
795
|
-
const query = timeRangeQuery(timeRange, accessToken);
|
|
796
|
-
const synced = {
|
|
797
|
-
activities: 0, dailies: 0, sleep: 0, body: 0, menstruation: 0,
|
|
798
|
-
bloodPressures: 0, skinTemp: 0, userMetrics: 0,
|
|
799
|
-
hrv: 0, stressDetails: 0, pulseOx: 0, respiration: 0,
|
|
800
|
-
};
|
|
801
|
-
const errors = [];
|
|
802
|
-
// ── Activities ──────────────────────────────────────────────────────────
|
|
803
|
-
try {
|
|
804
|
-
const { data: activities, error } = await getActivities({ client: wellnessClient, query });
|
|
805
|
-
if (error || !activities)
|
|
806
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
807
|
-
for (const activity of activities) {
|
|
808
|
-
try {
|
|
809
|
-
const data = transformActivity(activity);
|
|
810
|
-
await ctx.runMutation(api.public.ingestActivity, {
|
|
811
|
-
connectionId,
|
|
812
|
-
userId,
|
|
813
|
-
...data,
|
|
814
|
-
});
|
|
815
|
-
synced.activities++;
|
|
816
|
-
}
|
|
817
|
-
catch (err) {
|
|
818
|
-
errors.push({
|
|
819
|
-
type: "activity",
|
|
820
|
-
id: activity.summaryId ?? String(activity.activityId),
|
|
821
|
-
error: err instanceof Error ? err.message : String(err),
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
catch (err) {
|
|
827
|
-
errors.push({
|
|
828
|
-
type: "activity",
|
|
829
|
-
id: "fetch",
|
|
830
|
-
error: err instanceof Error ? err.message : String(err),
|
|
831
|
-
});
|
|
832
|
-
}
|
|
833
|
-
// ── Dailies ─────────────────────────────────────────────────────────────
|
|
834
|
-
try {
|
|
835
|
-
const { data: dailies, error } = await getDailies({ client: wellnessClient, query });
|
|
836
|
-
if (error || !dailies)
|
|
837
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
838
|
-
for (const daily of dailies) {
|
|
839
|
-
try {
|
|
840
|
-
const data = transformDailies(daily);
|
|
841
|
-
if (!data)
|
|
842
|
-
continue;
|
|
843
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
844
|
-
connectionId,
|
|
845
|
-
userId,
|
|
846
|
-
...data,
|
|
847
|
-
});
|
|
848
|
-
synced.dailies++;
|
|
849
|
-
}
|
|
850
|
-
catch (err) {
|
|
851
|
-
errors.push({
|
|
852
|
-
type: "daily",
|
|
853
|
-
id: daily.summaryId ?? daily.calendarDate ?? "unknown",
|
|
854
|
-
error: err instanceof Error ? err.message : String(err),
|
|
855
|
-
});
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
catch (err) {
|
|
860
|
-
errors.push({
|
|
861
|
-
type: "daily",
|
|
862
|
-
id: "fetch",
|
|
863
|
-
error: err instanceof Error ? err.message : String(err),
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
// ── Sleep ───────────────────────────────────────────────────────────────
|
|
867
|
-
try {
|
|
868
|
-
const { data: sleeps, error } = await getSleeps({ client: wellnessClient, query });
|
|
869
|
-
if (error || !sleeps)
|
|
870
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
871
|
-
for (const sleep of sleeps) {
|
|
872
|
-
try {
|
|
873
|
-
const data = transformSleeps(sleep);
|
|
874
|
-
await ctx.runMutation(api.public.ingestSleep, {
|
|
875
|
-
connectionId,
|
|
876
|
-
userId,
|
|
877
|
-
...data,
|
|
878
|
-
});
|
|
879
|
-
synced.sleep++;
|
|
880
|
-
}
|
|
881
|
-
catch (err) {
|
|
882
|
-
errors.push({
|
|
883
|
-
type: "sleep",
|
|
884
|
-
id: sleep.summaryId ?? sleep.calendarDate ?? "unknown",
|
|
885
|
-
error: err instanceof Error ? err.message : String(err),
|
|
886
|
-
});
|
|
887
|
-
}
|
|
888
|
-
}
|
|
734
|
+
const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
|
|
735
|
+
const plannedWorkout = await ctx.runQuery(api.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
|
|
736
|
+
if (!plannedWorkout) {
|
|
737
|
+
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
889
738
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
id: "fetch",
|
|
894
|
-
error: err instanceof Error ? err.message : String(err),
|
|
895
|
-
});
|
|
739
|
+
const providerWorkoutId = plannedWorkout.metadata?.provider_workout_id;
|
|
740
|
+
if (!providerWorkoutId) {
|
|
741
|
+
throw new Error("No Garmin workout ID found on this planned workout. Push the workout first via pushWorkout.");
|
|
896
742
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
if (error || !bodyComps)
|
|
901
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
902
|
-
for (const body of bodyComps) {
|
|
903
|
-
try {
|
|
904
|
-
const data = transformBodyComposition(body);
|
|
905
|
-
if (!data)
|
|
906
|
-
continue;
|
|
907
|
-
await ctx.runMutation(api.public.ingestBody, {
|
|
908
|
-
connectionId,
|
|
909
|
-
userId,
|
|
910
|
-
...data,
|
|
911
|
-
});
|
|
912
|
-
synced.body++;
|
|
913
|
-
}
|
|
914
|
-
catch (err) {
|
|
915
|
-
errors.push({
|
|
916
|
-
type: "body",
|
|
917
|
-
id: body.summaryId ?? String(body.measurementTimeInSeconds),
|
|
918
|
-
error: err instanceof Error ? err.message : String(err),
|
|
919
|
-
});
|
|
920
|
-
}
|
|
921
|
-
}
|
|
743
|
+
const scheduleDate = args.date ?? plannedWorkout.metadata?.planned_date;
|
|
744
|
+
if (!scheduleDate) {
|
|
745
|
+
throw new Error("No date provided and no planned_date on the workout. Provide a date argument.");
|
|
922
746
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
747
|
+
const trainingClient = createTrainingClient(accessToken);
|
|
748
|
+
const existingScheduleId = plannedWorkout.metadata?.provider_schedule_id;
|
|
749
|
+
let scheduleId;
|
|
750
|
+
if (existingScheduleId) {
|
|
751
|
+
// Update existing schedule on Garmin
|
|
752
|
+
const numericScheduleId = Number(existingScheduleId);
|
|
753
|
+
const { error: updateError } = await sdkUpdateWorkoutSchedule({
|
|
754
|
+
client: trainingClient,
|
|
755
|
+
body: { workoutId: Number(providerWorkoutId), date: scheduleDate },
|
|
756
|
+
path: { workoutScheduleId: numericScheduleId },
|
|
928
757
|
});
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
try {
|
|
932
|
-
const { data: records, error } = await getMct({ client: wellnessClient, query });
|
|
933
|
-
if (error || !records)
|
|
934
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
935
|
-
for (const record of records) {
|
|
936
|
-
try {
|
|
937
|
-
const data = transformMenstrualCycleTracking(record);
|
|
938
|
-
await ctx.runMutation(api.public.ingestMenstruation, {
|
|
939
|
-
connectionId,
|
|
940
|
-
userId,
|
|
941
|
-
...data,
|
|
942
|
-
});
|
|
943
|
-
synced.menstruation++;
|
|
944
|
-
}
|
|
945
|
-
catch (err) {
|
|
946
|
-
errors.push({
|
|
947
|
-
type: "menstruation",
|
|
948
|
-
id: record.summaryId ?? record.periodStartDate ?? "unknown",
|
|
949
|
-
error: err instanceof Error ? err.message : String(err),
|
|
950
|
-
});
|
|
951
|
-
}
|
|
758
|
+
if (updateError) {
|
|
759
|
+
throw new Error(`Garmin API error updating schedule: ${JSON.stringify(updateError)}`);
|
|
952
760
|
}
|
|
761
|
+
scheduleId = numericScheduleId;
|
|
953
762
|
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
763
|
+
else {
|
|
764
|
+
// Create new schedule on Garmin
|
|
765
|
+
const { data: createdScheduleId, error: scheduleError } = await sdkCreateWorkoutSchedule({
|
|
766
|
+
client: trainingClient,
|
|
767
|
+
body: { workoutId: Number(providerWorkoutId), date: scheduleDate },
|
|
959
768
|
});
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
try {
|
|
963
|
-
const { data: bpRecords, error } = await getBloodPressures({ client: wellnessClient, query });
|
|
964
|
-
if (error || !bpRecords)
|
|
965
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
966
|
-
for (const bp of bpRecords) {
|
|
967
|
-
try {
|
|
968
|
-
const data = transformBloodPressure(bp);
|
|
969
|
-
if (!data)
|
|
970
|
-
continue;
|
|
971
|
-
await ctx.runMutation(api.public.ingestBody, {
|
|
972
|
-
connectionId, userId, ...data,
|
|
973
|
-
});
|
|
974
|
-
synced.bloodPressures++;
|
|
975
|
-
}
|
|
976
|
-
catch (err) {
|
|
977
|
-
errors.push({
|
|
978
|
-
type: "bloodPressure",
|
|
979
|
-
id: bp.summaryId ?? String(bp.measurementTimeInSeconds),
|
|
980
|
-
error: err instanceof Error ? err.message : String(err),
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
catch (err) {
|
|
986
|
-
errors.push({ type: "bloodPressure", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
987
|
-
}
|
|
988
|
-
// ── Skin Temperature (→ body) ──────────────────────────────────────────
|
|
989
|
-
try {
|
|
990
|
-
const { data: skinRecords, error } = await getSkinTemp({ client: wellnessClient, query });
|
|
991
|
-
if (error || !skinRecords)
|
|
992
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
993
|
-
for (const skin of skinRecords) {
|
|
994
|
-
try {
|
|
995
|
-
const data = transformSkinTemperature(skin);
|
|
996
|
-
if (!data)
|
|
997
|
-
continue;
|
|
998
|
-
await ctx.runMutation(api.public.ingestBody, {
|
|
999
|
-
connectionId, userId, ...data,
|
|
1000
|
-
});
|
|
1001
|
-
synced.skinTemp++;
|
|
1002
|
-
}
|
|
1003
|
-
catch (err) {
|
|
1004
|
-
errors.push({
|
|
1005
|
-
type: "skinTemp",
|
|
1006
|
-
id: skin.summaryId ?? skin.calendarDate ?? "unknown",
|
|
1007
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1008
|
-
});
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
catch (err) {
|
|
1013
|
-
errors.push({ type: "skinTemp", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1014
|
-
}
|
|
1015
|
-
// ── User Metrics (→ body) ──────────────────────────────────────────────
|
|
1016
|
-
try {
|
|
1017
|
-
const { data: metricsRecords, error } = await getUserMetrics({ client: wellnessClient, query });
|
|
1018
|
-
if (error || !metricsRecords)
|
|
1019
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1020
|
-
for (const metrics of metricsRecords) {
|
|
1021
|
-
try {
|
|
1022
|
-
const data = transformUserMetrics(metrics);
|
|
1023
|
-
if (!data)
|
|
1024
|
-
continue;
|
|
1025
|
-
await ctx.runMutation(api.public.ingestBody, {
|
|
1026
|
-
connectionId, userId, ...data,
|
|
1027
|
-
});
|
|
1028
|
-
synced.userMetrics++;
|
|
1029
|
-
}
|
|
1030
|
-
catch (err) {
|
|
1031
|
-
errors.push({
|
|
1032
|
-
type: "userMetrics",
|
|
1033
|
-
id: metrics.summaryId ?? metrics.calendarDate ?? "unknown",
|
|
1034
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1035
|
-
});
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
catch (err) {
|
|
1040
|
-
errors.push({ type: "userMetrics", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1041
|
-
}
|
|
1042
|
-
// ── HRV (enriches daily) ──────────────────────────────────────────────
|
|
1043
|
-
try {
|
|
1044
|
-
const { data: hrvRecords, error } = await getHrv({ client: wellnessClient, query });
|
|
1045
|
-
if (error || !hrvRecords)
|
|
1046
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1047
|
-
for (const hrv of hrvRecords) {
|
|
1048
|
-
try {
|
|
1049
|
-
const data = transformHRVSummary(hrv);
|
|
1050
|
-
if (data) {
|
|
1051
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
1052
|
-
connectionId, userId, ...data,
|
|
1053
|
-
});
|
|
1054
|
-
synced.hrv++;
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
catch (err) {
|
|
1058
|
-
errors.push({
|
|
1059
|
-
type: "hrv",
|
|
1060
|
-
id: hrv.summaryId ?? hrv.calendarDate ?? "unknown",
|
|
1061
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1062
|
-
});
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
catch (err) {
|
|
1067
|
-
errors.push({ type: "hrv", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1068
|
-
}
|
|
1069
|
-
// ── Stress Details (enriches daily) ────────────────────────────────────
|
|
1070
|
-
try {
|
|
1071
|
-
const { data: stressRecords, error } = await getStressDetails({ client: wellnessClient, query });
|
|
1072
|
-
if (error || !stressRecords)
|
|
1073
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1074
|
-
for (const stress of stressRecords) {
|
|
1075
|
-
try {
|
|
1076
|
-
const data = transformStress(stress);
|
|
1077
|
-
if (data) {
|
|
1078
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
1079
|
-
connectionId, userId, ...data,
|
|
1080
|
-
});
|
|
1081
|
-
synced.stressDetails++;
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
catch (err) {
|
|
1085
|
-
errors.push({
|
|
1086
|
-
type: "stressDetails",
|
|
1087
|
-
id: stress.summaryId ?? stress.calendarDate ?? "unknown",
|
|
1088
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1089
|
-
});
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
catch (err) {
|
|
1094
|
-
errors.push({ type: "stressDetails", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1095
|
-
}
|
|
1096
|
-
// ── Pulse Ox (enriches daily) ──────────────────────────────────────────
|
|
1097
|
-
try {
|
|
1098
|
-
const { data: pulseOxRecords, error } = await getPulseox({ client: wellnessClient, query });
|
|
1099
|
-
if (error || !pulseOxRecords)
|
|
1100
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1101
|
-
for (const po of pulseOxRecords) {
|
|
1102
|
-
try {
|
|
1103
|
-
const data = transformPulseOx(po);
|
|
1104
|
-
if (data) {
|
|
1105
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
1106
|
-
connectionId, userId, ...data,
|
|
1107
|
-
});
|
|
1108
|
-
synced.pulseOx++;
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
catch (err) {
|
|
1112
|
-
errors.push({
|
|
1113
|
-
type: "pulseOx",
|
|
1114
|
-
id: po.summaryId ?? po.calendarDate ?? "unknown",
|
|
1115
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
769
|
+
if (scheduleError) {
|
|
770
|
+
throw new Error(`Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`);
|
|
1118
771
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
errors.push({ type: "pulseOx", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1122
|
-
}
|
|
1123
|
-
// ── Respiration (enriches daily) ───────────────────────────────────────
|
|
1124
|
-
try {
|
|
1125
|
-
const { data: respRecords, error } = await getRespiration({ client: wellnessClient, query });
|
|
1126
|
-
if (error || !respRecords)
|
|
1127
|
-
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1128
|
-
for (const resp of respRecords) {
|
|
1129
|
-
try {
|
|
1130
|
-
const data = transformRespiration(resp);
|
|
1131
|
-
if (data) {
|
|
1132
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
1133
|
-
connectionId, userId, ...data,
|
|
1134
|
-
});
|
|
1135
|
-
synced.respiration++;
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
catch (err) {
|
|
1139
|
-
errors.push({
|
|
1140
|
-
type: "respiration",
|
|
1141
|
-
id: resp.summaryId ?? "unknown",
|
|
1142
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
772
|
+
if (createdScheduleId == null) {
|
|
773
|
+
throw new Error("Garmin API did not return a scheduleId after creation.");
|
|
1145
774
|
}
|
|
775
|
+
scheduleId = createdScheduleId;
|
|
1146
776
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
777
|
+
// Persist the Garmin schedule ID back on the planned workout
|
|
778
|
+
const _ingested = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
779
|
+
...plannedWorkout,
|
|
780
|
+
_id: undefined,
|
|
781
|
+
_creationTime: undefined,
|
|
782
|
+
metadata: {
|
|
783
|
+
...plannedWorkout.metadata,
|
|
784
|
+
provider_schedule_id: String(scheduleId),
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
return { garminScheduleId: scheduleId };
|
|
1151
788
|
},
|
|
1152
789
|
});
|
|
1153
|
-
// ───
|
|
1154
|
-
|
|
1155
|
-
* Push a planned workout from Soma's DB to Garmin Connect.
|
|
1156
|
-
*
|
|
1157
|
-
* Reads the planned workout document, transforms it to Garmin Training API V2
|
|
1158
|
-
* format, creates the workout at Garmin, and optionally schedules it if a
|
|
1159
|
-
* `planned_date` is set in the metadata.
|
|
1160
|
-
*
|
|
1161
|
-
* Returns the Garmin workout ID and schedule ID (if scheduled).
|
|
1162
|
-
*/
|
|
1163
|
-
export const pushPlannedWorkout = action({
|
|
790
|
+
// ─── Delete ───────────────────────────────────────────────────────────────────
|
|
791
|
+
export const deleteWorkout = action({
|
|
1164
792
|
args: {
|
|
1165
793
|
userId: v.string(),
|
|
1166
794
|
clientId: v.string(),
|
|
1167
795
|
clientSecret: v.string(),
|
|
1168
796
|
plannedWorkoutId: v.string(),
|
|
1169
|
-
workoutProvider: v.optional(v.string()),
|
|
1170
797
|
},
|
|
1171
798
|
handler: async (ctx, args) => {
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
799
|
+
const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
|
|
800
|
+
const plannedWorkout = await ctx.runQuery(api.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
|
|
801
|
+
if (!plannedWorkout) {
|
|
802
|
+
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
1176
803
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
804
|
+
const providerWorkoutId = plannedWorkout.metadata?.provider_workout_id;
|
|
805
|
+
if (!providerWorkoutId) {
|
|
806
|
+
throw new Error("No Garmin workout ID found on this planned workout. Nothing to delete.");
|
|
1179
807
|
}
|
|
1180
|
-
const
|
|
1181
|
-
const
|
|
1182
|
-
|
|
808
|
+
const trainingClient = createTrainingClient(accessToken);
|
|
809
|
+
const { error: deleteError } = await sdkDeleteWorkoutV2({
|
|
810
|
+
client: trainingClient,
|
|
811
|
+
path: { workoutId: Number(providerWorkoutId) },
|
|
1183
812
|
});
|
|
1184
|
-
if (
|
|
1185
|
-
throw new Error(
|
|
1186
|
-
"The connection may have been created before token storage was available.");
|
|
1187
|
-
}
|
|
1188
|
-
// Always force-refresh the token for Training API calls to rule out
|
|
1189
|
-
// stale tokens (the initial sync swallows 401 errors silently).
|
|
1190
|
-
let accessToken = tokenDoc.accessToken;
|
|
1191
|
-
if (tokenDoc.refreshToken) {
|
|
1192
|
-
try {
|
|
1193
|
-
const refreshed = await refreshToken({
|
|
1194
|
-
clientId: args.clientId,
|
|
1195
|
-
clientSecret: args.clientSecret,
|
|
1196
|
-
refreshToken: tokenDoc.refreshToken,
|
|
1197
|
-
});
|
|
1198
|
-
accessToken = refreshed.access_token;
|
|
1199
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1200
|
-
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
1201
|
-
const _refreshed = await ctx.runMutation(internal.garmin.private.storeTokens, {
|
|
1202
|
-
connectionId,
|
|
1203
|
-
accessToken: refreshed.access_token,
|
|
1204
|
-
refreshToken: refreshed.refresh_token,
|
|
1205
|
-
expiresAt: newExpiresAt,
|
|
1206
|
-
});
|
|
1207
|
-
}
|
|
1208
|
-
catch (refreshErr) {
|
|
1209
|
-
throw new Error(`Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
|
|
1210
|
-
"The user may need to reconnect their Garmin account.");
|
|
1211
|
-
}
|
|
813
|
+
if (deleteError) {
|
|
814
|
+
throw new Error(`Garmin API error deleting workout: ${JSON.stringify(deleteError)}`);
|
|
1212
815
|
}
|
|
816
|
+
// Clear both provider IDs — deleting a workout on Garmin cascades to its schedules
|
|
817
|
+
const _ingested = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
818
|
+
...plannedWorkout,
|
|
819
|
+
_id: undefined,
|
|
820
|
+
_creationTime: undefined,
|
|
821
|
+
metadata: {
|
|
822
|
+
...plannedWorkout.metadata,
|
|
823
|
+
provider_workout_id: undefined,
|
|
824
|
+
provider_schedule_id: undefined,
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
return null;
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
export const deleteSchedule = action({
|
|
831
|
+
args: {
|
|
832
|
+
userId: v.string(),
|
|
833
|
+
clientId: v.string(),
|
|
834
|
+
clientSecret: v.string(),
|
|
835
|
+
plannedWorkoutId: v.string(),
|
|
836
|
+
},
|
|
837
|
+
handler: async (ctx, args) => {
|
|
838
|
+
const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
|
|
1213
839
|
const plannedWorkout = await ctx.runQuery(api.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
|
|
1214
840
|
if (!plannedWorkout) {
|
|
1215
841
|
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
1216
842
|
}
|
|
1217
|
-
const
|
|
1218
|
-
|
|
843
|
+
const providerScheduleId = plannedWorkout.metadata?.provider_schedule_id;
|
|
844
|
+
if (!providerScheduleId) {
|
|
845
|
+
throw new Error("No Garmin schedule ID found on this planned workout. Nothing to delete.");
|
|
846
|
+
}
|
|
1219
847
|
const trainingClient = createTrainingClient(accessToken);
|
|
1220
|
-
const {
|
|
848
|
+
const { error: deleteError } = await sdkDeleteWorkoutSchedule({
|
|
1221
849
|
client: trainingClient,
|
|
1222
|
-
|
|
850
|
+
path: { workoutScheduleId: Number(providerScheduleId) },
|
|
1223
851
|
});
|
|
1224
|
-
if (
|
|
1225
|
-
throw new Error(`Garmin API error
|
|
1226
|
-
}
|
|
1227
|
-
if (!created.workoutId) {
|
|
1228
|
-
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
852
|
+
if (deleteError) {
|
|
853
|
+
throw new Error(`Garmin API error deleting schedule: ${JSON.stringify(deleteError)}`);
|
|
1229
854
|
}
|
|
1230
|
-
|
|
1231
|
-
const plannedDate = plannedWorkout.metadata?.planned_date;
|
|
1232
|
-
if (plannedDate) {
|
|
1233
|
-
const { data: scheduleId, error: scheduleError } = await sdkCreateWorkoutSchedule({
|
|
1234
|
-
client: trainingClient,
|
|
1235
|
-
body: { workoutId: Number(created.workoutId), date: plannedDate },
|
|
1236
|
-
});
|
|
1237
|
-
if (scheduleError) {
|
|
1238
|
-
throw new Error(`Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`);
|
|
1239
|
-
}
|
|
1240
|
-
garminScheduleId = scheduleId ?? null;
|
|
1241
|
-
}
|
|
1242
|
-
// Store the Garmin workout/schedule IDs back on the planned workout
|
|
1243
|
-
// so the host app can match completed activities to planned sessions.
|
|
855
|
+
// Clear only the schedule ID — the workout still exists on Garmin
|
|
1244
856
|
const _ingested = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
1245
857
|
...plannedWorkout,
|
|
1246
858
|
_id: undefined,
|
|
1247
859
|
_creationTime: undefined,
|
|
1248
860
|
metadata: {
|
|
1249
861
|
...plannedWorkout.metadata,
|
|
1250
|
-
|
|
1251
|
-
provider_schedule_id: garminScheduleId != null ? String(garminScheduleId) : undefined,
|
|
862
|
+
provider_schedule_id: undefined,
|
|
1252
863
|
},
|
|
1253
864
|
});
|
|
1254
|
-
return
|
|
1255
|
-
garminWorkoutId: created.workoutId,
|
|
1256
|
-
garminScheduleId,
|
|
1257
|
-
};
|
|
865
|
+
return null;
|
|
1258
866
|
},
|
|
1259
867
|
});
|
|
1260
868
|
//# sourceMappingURL=public.js.map
|