@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.
Files changed (88) hide show
  1. package/dist/client/index.d.ts +9 -4
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/component/_generated/component.d.ts +50 -0
  5. package/dist/component/_generated/component.d.ts.map +1 -0
  6. package/dist/component/_generated/component.js +11 -0
  7. package/dist/component/_generated/component.js.map +1 -0
  8. package/dist/component/backfillJobs.d.ts +11 -11
  9. package/dist/component/connections.d.ts +9 -9
  10. package/dist/component/connections.d.ts.map +1 -1
  11. package/dist/component/connections.js +2 -0
  12. package/dist/component/connections.js.map +1 -1
  13. package/dist/component/dataPoints.d.ts +5 -5
  14. package/dist/component/events.d.ts +13 -13
  15. package/dist/component/garminBackfill.d.ts +2 -2
  16. package/dist/component/garminWebhooks.d.ts +2 -2
  17. package/dist/component/garminWebhooks.d.ts.map +1 -1
  18. package/dist/component/garminWebhooks.js +2 -0
  19. package/dist/component/garminWebhooks.js.map +1 -1
  20. package/dist/component/lifecycle.d.ts +1 -1
  21. package/dist/component/lifecycle.d.ts.map +1 -1
  22. package/dist/component/lifecycle.js +2 -0
  23. package/dist/component/lifecycle.js.map +1 -1
  24. package/dist/component/oauthStates.d.ts +3 -3
  25. package/dist/component/schema.d.ts +26 -26
  26. package/dist/component/sdkPush.d.ts +11 -11
  27. package/dist/component/summaries.d.ts +4 -4
  28. package/dist/component/syncJobs.d.ts +23 -23
  29. package/dist/component/syncWorkflow.d.ts +2 -2
  30. package/dist/test.d.ts +421 -0
  31. package/dist/test.d.ts.map +1 -0
  32. package/dist/test.js +17 -0
  33. package/dist/test.js.map +1 -0
  34. package/package.json +12 -2
  35. package/src/client/_generated/_ignore.ts +2 -0
  36. package/src/client/index.test.ts +52 -0
  37. package/src/client/index.ts +784 -0
  38. package/src/client/types.ts +533 -0
  39. package/src/component/_generated/_ignore.ts +2 -0
  40. package/src/component/_generated/api.ts +16 -0
  41. package/src/component/_generated/component.ts +74 -0
  42. package/src/component/_generated/dataModel.ts +40 -0
  43. package/src/component/_generated/server.ts +48 -0
  44. package/src/component/backfillJobs.test.ts +47 -0
  45. package/src/component/backfillJobs.ts +245 -0
  46. package/src/component/connections.test.ts +297 -0
  47. package/src/component/connections.ts +329 -0
  48. package/src/component/convex.config.ts +7 -0
  49. package/src/component/dataPoints.test.ts +282 -0
  50. package/src/component/dataPoints.ts +305 -0
  51. package/src/component/dataSources.test.ts +247 -0
  52. package/src/component/dataSources.ts +109 -0
  53. package/src/component/events.test.ts +380 -0
  54. package/src/component/events.ts +288 -0
  55. package/src/component/garminBackfill.ts +343 -0
  56. package/src/component/garminWebhooks.test.ts +609 -0
  57. package/src/component/garminWebhooks.ts +656 -0
  58. package/src/component/httpHandlers.ts +153 -0
  59. package/src/component/lifecycle.test.ts +179 -0
  60. package/src/component/lifecycle.ts +87 -0
  61. package/src/component/menstrualCycles.ts +124 -0
  62. package/src/component/oauthActions.ts +261 -0
  63. package/src/component/oauthStates.test.ts +170 -0
  64. package/src/component/oauthStates.ts +85 -0
  65. package/src/component/providerSettings.ts +66 -0
  66. package/src/component/providers/additionalProviders.test.ts +401 -0
  67. package/src/component/providers/garmin.ts +1169 -0
  68. package/src/component/providers/oauth.test.ts +174 -0
  69. package/src/component/providers/oauth.ts +246 -0
  70. package/src/component/providers/polar.ts +220 -0
  71. package/src/component/providers/registry.ts +37 -0
  72. package/src/component/providers/strava.test.ts +195 -0
  73. package/src/component/providers/strava.ts +253 -0
  74. package/src/component/providers/suunto.ts +592 -0
  75. package/src/component/providers/types.ts +189 -0
  76. package/src/component/providers/whoop.ts +600 -0
  77. package/src/component/schema.ts +339 -0
  78. package/src/component/sdkPush.test.ts +367 -0
  79. package/src/component/sdkPush.ts +440 -0
  80. package/src/component/summaries.test.ts +201 -0
  81. package/src/component/summaries.ts +143 -0
  82. package/src/component/syncJobs.test.ts +254 -0
  83. package/src/component/syncJobs.ts +140 -0
  84. package/src/component/syncWorkflow.test.ts +87 -0
  85. package/src/component/syncWorkflow.ts +739 -0
  86. package/src/component/test.setup.ts +6 -0
  87. package/src/component/workflowManager.ts +19 -0
  88. package/src/test.ts +25 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * HTTP action handlers for OAuth callbacks and provider webhooks.
3
+ *
4
+ * These are Convex httpAction endpoints that handle:
5
+ * 1. OAuth callback redirects (GET /oauth/callback)
6
+ * 2. Provider webhook pushes (POST /webhooks/:provider)
7
+ */
8
+
9
+ import { internal } from "./_generated/api";
10
+ import { httpAction } from "./_generated/server";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // OAuth callback handler
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Handles the OAuth redirect callback from a provider.
18
+ *
19
+ * Expected query params:
20
+ * - state: The OAuth state token
21
+ * - code: The authorization code
22
+ *
23
+ * The host app must mount this at a route and pass client credentials
24
+ * via the component configuration.
25
+ */
26
+ export const oauthCallback = httpAction(async (ctx, request) => {
27
+ const url = new URL(request.url);
28
+ const state = url.searchParams.get("state");
29
+ const code = url.searchParams.get("code");
30
+ const error = url.searchParams.get("error");
31
+
32
+ if (error) {
33
+ return new Response(JSON.stringify({ error: `OAuth error: ${error}` }), {
34
+ status: 400,
35
+ headers: { "Content-Type": "application/json" },
36
+ });
37
+ }
38
+
39
+ if (!state || !code) {
40
+ return new Response(JSON.stringify({ error: "Missing state or code parameter" }), {
41
+ status: 400,
42
+ headers: { "Content-Type": "application/json" },
43
+ });
44
+ }
45
+
46
+ // Look up the state to find which provider and user this is for
47
+ const oauthState = await ctx.runQuery(internal.oauthStates.getByState, {
48
+ state,
49
+ });
50
+
51
+ if (!oauthState) {
52
+ return new Response(JSON.stringify({ error: "Invalid or expired OAuth state" }), {
53
+ status: 400,
54
+ headers: { "Content-Type": "application/json" },
55
+ });
56
+ }
57
+
58
+ // Return the state and code to be processed by the client-side handler.
59
+ // The host app will call handleCallback action with credentials.
60
+ return new Response(
61
+ JSON.stringify({
62
+ state,
63
+ code,
64
+ provider: oauthState.provider,
65
+ userId: oauthState.userId,
66
+ }),
67
+ { status: 200, headers: { "Content-Type": "application/json" } },
68
+ );
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Strava webhook verification (GET)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Strava webhook subscription verification.
77
+ * Strava sends a GET request with hub.challenge to verify the endpoint.
78
+ */
79
+ export const stravaWebhookVerify = httpAction(async (_ctx, request) => {
80
+ const url = new URL(request.url);
81
+ const mode = url.searchParams.get("hub.mode");
82
+ const challenge = url.searchParams.get("hub.challenge");
83
+ const _verifyToken = url.searchParams.get("hub.verify_token");
84
+
85
+ if (mode !== "subscribe" || !challenge) {
86
+ return new Response("Invalid request", { status: 400 });
87
+ }
88
+
89
+ // The verify token should match what was set during subscription creation.
90
+ // For now, accept any verify token — the host app should validate this
91
+ // in their configuration.
92
+ return new Response(JSON.stringify({ "hub.challenge": challenge }), {
93
+ status: 200,
94
+ headers: { "Content-Type": "application/json" },
95
+ });
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Strava webhook events (POST)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Handles incoming Strava webhook events.
104
+ *
105
+ * Strava pushes activity create/update/delete events here.
106
+ * We process activity creates/updates by fetching the full activity
107
+ * and storing it. Deletes are handled by removing the corresponding event.
108
+ */
109
+ export const stravaWebhookEvent = httpAction(async (_ctx, request) => {
110
+ try {
111
+ const body = await request.json();
112
+
113
+ // Strava webhook payload format:
114
+ // { object_type: "activity", object_id: 123, aspect_type: "create"|"update"|"delete",
115
+ // owner_id: 456, subscription_id: 789 }
116
+ const { object_type, object_id, aspect_type, owner_id } = body;
117
+
118
+ if (object_type !== "activity") {
119
+ // We only handle activity events for now
120
+ return new Response("OK", { status: 200 });
121
+ }
122
+
123
+ const ownerId = String(owner_id);
124
+ const connection = await _ctx.runQuery(internal.connections.getByProviderUser, {
125
+ provider: "strava",
126
+ providerUserId: ownerId,
127
+ });
128
+
129
+ if (!connection) {
130
+ return new Response("OK", { status: 200 });
131
+ }
132
+
133
+ if (aspect_type === "delete") {
134
+ await _ctx.runMutation(internal.events.deleteByExternalId, {
135
+ externalId: `strava-${object_id}`,
136
+ });
137
+ return new Response("OK", { status: 200 });
138
+ }
139
+
140
+ const now = Date.now();
141
+ await _ctx.runMutation(internal.syncWorkflow.requestConnectionSync, {
142
+ connectionId: connection._id,
143
+ mode: "webhook",
144
+ triggerSource: `strava:${aspect_type}:${object_id}`,
145
+ windowStart: now - 30 * 24 * 60 * 60 * 1000,
146
+ windowEnd: now + 5 * 60 * 1000,
147
+ });
148
+
149
+ return new Response("OK", { status: 200 });
150
+ } catch {
151
+ return new Response("Internal error", { status: 500 });
152
+ }
153
+ });
@@ -0,0 +1,179 @@
1
+ import { convexTest } from "convex-test";
2
+ import { describe, expect, it } from "vitest";
3
+ import schema from "./schema";
4
+ import { modules } from "./test.setup";
5
+
6
+ describe("lifecycle", () => {
7
+ describe("deleteAllUserData", () => {
8
+ it("deletes all data across all tables for a user", async () => {
9
+ const t = convexTest(schema, modules);
10
+
11
+ // Seed user-1 data
12
+ const { connId, dsId } = await t.run(async (ctx) => {
13
+ const connId = await ctx.db.insert("connections", {
14
+ userId: "user-1",
15
+ provider: "garmin",
16
+ accessToken: "token",
17
+ status: "active",
18
+ });
19
+ const dsId = await ctx.db.insert("dataSources", {
20
+ userId: "user-1",
21
+ provider: "garmin",
22
+ connectionId: connId,
23
+ });
24
+ await ctx.db.insert("dataPoints", {
25
+ dataSourceId: dsId,
26
+ seriesType: "heart_rate",
27
+ recordedAt: 1710000000000,
28
+ value: 72,
29
+ });
30
+ await ctx.db.insert("events", {
31
+ dataSourceId: dsId,
32
+ userId: "user-1",
33
+ category: "workout",
34
+ type: "running",
35
+ startDatetime: 1710000000000,
36
+ });
37
+ await ctx.db.insert("dailySummaries", {
38
+ userId: "user-1",
39
+ date: "2026-03-15",
40
+ category: "activity",
41
+ totalSteps: 10000,
42
+ });
43
+ await ctx.db.insert("syncJobs", {
44
+ connectionId: connId,
45
+ userId: "user-1",
46
+ provider: "garmin",
47
+ idempotencyKey: "lifecycle-1",
48
+ status: "completed",
49
+ startedAt: 1710000000000,
50
+ });
51
+ await ctx.db.insert("backfillJobs", {
52
+ connectionId: connId,
53
+ userId: "user-1",
54
+ provider: "garmin",
55
+ dataType: "dailies",
56
+ status: "completed",
57
+ startedAt: 1710000000000,
58
+ });
59
+ return { connId, dsId };
60
+ });
61
+
62
+ // Seed user-2 data (should NOT be deleted)
63
+ await t.run(async (ctx) => {
64
+ const c2 = await ctx.db.insert("connections", {
65
+ userId: "user-2",
66
+ provider: "strava",
67
+ accessToken: "token-2",
68
+ status: "active",
69
+ });
70
+ const ds2 = await ctx.db.insert("dataSources", {
71
+ userId: "user-2",
72
+ provider: "strava",
73
+ connectionId: c2,
74
+ });
75
+ await ctx.db.insert("events", {
76
+ dataSourceId: ds2,
77
+ userId: "user-2",
78
+ category: "workout",
79
+ type: "cycling",
80
+ startDatetime: 1710000000000,
81
+ });
82
+ });
83
+
84
+ // Delete user-1 data (simulate what lifecycle.deleteAllUserData does)
85
+ await t.run(async (ctx) => {
86
+ // Delete backfill jobs
87
+ const backfills = await ctx.db
88
+ .query("backfillJobs")
89
+ .withIndex("by_connection", (idx) => idx.eq("connectionId", connId))
90
+ .collect();
91
+ for (const bf of backfills) await ctx.db.delete(bf._id);
92
+
93
+ // Delete connections
94
+ const conns = await ctx.db
95
+ .query("connections")
96
+ .withIndex("by_user", (idx) => idx.eq("userId", "user-1"))
97
+ .collect();
98
+ for (const c of conns) await ctx.db.delete(c._id);
99
+
100
+ // Delete data points
101
+ const points = await ctx.db
102
+ .query("dataPoints")
103
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", dsId))
104
+ .collect();
105
+ for (const p of points) await ctx.db.delete(p._id);
106
+
107
+ // Delete data sources
108
+ const sources = await ctx.db
109
+ .query("dataSources")
110
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", "user-1"))
111
+ .collect();
112
+ for (const s of sources) await ctx.db.delete(s._id);
113
+
114
+ // Delete events
115
+ const events = await ctx.db
116
+ .query("events")
117
+ .withIndex("by_user_category_time", (idx) => idx.eq("userId", "user-1"))
118
+ .collect();
119
+ for (const e of events) await ctx.db.delete(e._id);
120
+
121
+ // Delete summaries
122
+ const summaries = await ctx.db
123
+ .query("dailySummaries")
124
+ .withIndex("by_user_date", (idx) => idx.eq("userId", "user-1"))
125
+ .collect();
126
+ for (const s of summaries) await ctx.db.delete(s._id);
127
+
128
+ // Delete sync jobs
129
+ const jobs = await ctx.db
130
+ .query("syncJobs")
131
+ .withIndex("by_user", (idx) => idx.eq("userId", "user-1"))
132
+ .collect();
133
+ for (const j of jobs) await ctx.db.delete(j._id);
134
+ });
135
+
136
+ // Verify user-1 data is gone
137
+ const user1Conns = await t.run(async (ctx) => {
138
+ return await ctx.db
139
+ .query("connections")
140
+ .withIndex("by_user", (idx) => idx.eq("userId", "user-1"))
141
+ .collect();
142
+ });
143
+ expect(user1Conns).toHaveLength(0);
144
+
145
+ const user1Events = await t.run(async (ctx) => {
146
+ return await ctx.db
147
+ .query("events")
148
+ .withIndex("by_user_category_time", (idx) => idx.eq("userId", "user-1"))
149
+ .collect();
150
+ });
151
+ expect(user1Events).toHaveLength(0);
152
+
153
+ const user1Summaries = await t.run(async (ctx) => {
154
+ return await ctx.db
155
+ .query("dailySummaries")
156
+ .withIndex("by_user_date", (idx) => idx.eq("userId", "user-1"))
157
+ .collect();
158
+ });
159
+ expect(user1Summaries).toHaveLength(0);
160
+
161
+ // Verify user-2 data is intact
162
+ const user2Conns = await t.run(async (ctx) => {
163
+ return await ctx.db
164
+ .query("connections")
165
+ .withIndex("by_user", (idx) => idx.eq("userId", "user-2"))
166
+ .collect();
167
+ });
168
+ expect(user2Conns).toHaveLength(1);
169
+
170
+ const user2Events = await t.run(async (ctx) => {
171
+ return await ctx.db
172
+ .query("events")
173
+ .withIndex("by_user_category_time", (idx) => idx.eq("userId", "user-2"))
174
+ .collect();
175
+ });
176
+ expect(user2Events).toHaveLength(1);
177
+ });
178
+ });
179
+ });
@@ -0,0 +1,87 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "./_generated/server";
3
+
4
+ /**
5
+ * Delete ALL data for a user across all component tables.
6
+ * Used for GDPR compliance and account deletion.
7
+ *
8
+ * This is a "best-effort" deletion within a single mutation.
9
+ * For users with very large amounts of data, the host app should
10
+ * call this repeatedly or use a workflow.
11
+ */
12
+ export const deleteAllUserData = mutation({
13
+ args: { userId: v.string() },
14
+ returns: v.null(),
15
+ handler: async (ctx, args) => {
16
+ const { userId } = args;
17
+
18
+ // 1. Delete connections
19
+ const connections = await ctx.db
20
+ .query("connections")
21
+ .withIndex("by_user", (idx) => idx.eq("userId", userId))
22
+ .collect();
23
+ for (const conn of connections) {
24
+ // Delete backfill jobs for this connection
25
+ const backfills = await ctx.db
26
+ .query("backfillJobs")
27
+ .withIndex("by_connection", (idx) => idx.eq("connectionId", conn._id))
28
+ .collect();
29
+ for (const bf of backfills) {
30
+ await ctx.db.delete(bf._id);
31
+ }
32
+ await ctx.db.delete(conn._id);
33
+ }
34
+
35
+ // 2. Delete data sources and their data points
36
+ const sources = await ctx.db
37
+ .query("dataSources")
38
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", userId))
39
+ .collect();
40
+ for (const source of sources) {
41
+ // Delete data points in batches
42
+ let points = await ctx.db
43
+ .query("dataPoints")
44
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", source._id))
45
+ .take(500);
46
+ while (points.length > 0) {
47
+ for (const p of points) {
48
+ await ctx.db.delete(p._id);
49
+ }
50
+ points = await ctx.db
51
+ .query("dataPoints")
52
+ .withIndex("by_source_type_time", (idx) => idx.eq("dataSourceId", source._id))
53
+ .take(500);
54
+ }
55
+ await ctx.db.delete(source._id);
56
+ }
57
+
58
+ // 3. Delete events
59
+ const events = await ctx.db
60
+ .query("events")
61
+ .withIndex("by_user_category_time", (idx) => idx.eq("userId", userId))
62
+ .collect();
63
+ for (const event of events) {
64
+ await ctx.db.delete(event._id);
65
+ }
66
+
67
+ // 4. Delete daily summaries
68
+ const summaries = await ctx.db
69
+ .query("dailySummaries")
70
+ .withIndex("by_user_date", (idx) => idx.eq("userId", userId))
71
+ .collect();
72
+ for (const summary of summaries) {
73
+ await ctx.db.delete(summary._id);
74
+ }
75
+
76
+ // 5. Delete sync jobs
77
+ const jobs = await ctx.db
78
+ .query("syncJobs")
79
+ .withIndex("by_user", (idx) => idx.eq("userId", userId))
80
+ .collect();
81
+ for (const job of jobs) {
82
+ await ctx.db.delete(job._id);
83
+ }
84
+
85
+ return null;
86
+ },
87
+ });
@@ -0,0 +1,124 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, query } from "./_generated/server";
3
+ import { providerName } from "./schema";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Queries
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Get menstrual cycle records for a user within a date range.
11
+ */
12
+ export const getByUserDateRange = query({
13
+ args: {
14
+ userId: v.string(),
15
+ startDate: v.string(), // ISO date "2026-01-01"
16
+ endDate: v.string(),
17
+ },
18
+ returns: v.array(v.any()),
19
+ handler: async (ctx, args) => {
20
+ return await ctx.db
21
+ .query("menstrualCycles")
22
+ .withIndex("by_user_date", (idx) =>
23
+ idx
24
+ .eq("userId", args.userId)
25
+ .gte("periodStartDate", args.startDate)
26
+ .lte("periodStartDate", args.endDate),
27
+ )
28
+ .collect();
29
+ },
30
+ });
31
+
32
+ /**
33
+ * Get the latest menstrual cycle record for a user.
34
+ */
35
+ export const getLatest = query({
36
+ args: { userId: v.string() },
37
+ returns: v.any(),
38
+ handler: async (ctx, args) => {
39
+ return await ctx.db
40
+ .query("menstrualCycles")
41
+ .withIndex("by_user_date", (idx) => idx.eq("userId", args.userId))
42
+ .order("desc")
43
+ .first();
44
+ },
45
+ });
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Mutations
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Upsert a menstrual cycle record. Deduplicates by externalId.
53
+ */
54
+ export const upsert = internalMutation({
55
+ args: {
56
+ userId: v.string(),
57
+ provider: providerName,
58
+ externalId: v.optional(v.string()),
59
+ periodStartDate: v.string(),
60
+ dayInCycle: v.optional(v.number()),
61
+ cycleLength: v.optional(v.number()),
62
+ predictedCycleLength: v.optional(v.number()),
63
+ periodLength: v.optional(v.number()),
64
+ currentPhase: v.optional(v.number()),
65
+ currentPhaseType: v.optional(v.string()),
66
+ lengthOfCurrentPhase: v.optional(v.number()),
67
+ daysUntilNextPhase: v.optional(v.number()),
68
+ isPredictedCycle: v.optional(v.boolean()),
69
+ fertileWindowStart: v.optional(v.number()),
70
+ lengthOfFertileWindow: v.optional(v.number()),
71
+ lastUpdatedAt: v.optional(v.number()),
72
+ isPregnant: v.optional(v.boolean()),
73
+ pregnancyDueDate: v.optional(v.string()),
74
+ pregnancyOriginalDueDate: v.optional(v.string()),
75
+ pregnancyCycleStartDate: v.optional(v.string()),
76
+ pregnancyTitle: v.optional(v.string()),
77
+ numberOfBabies: v.optional(v.string()),
78
+ },
79
+ returns: v.id("menstrualCycles"),
80
+ handler: async (ctx, args) => {
81
+ // Dedup by externalId
82
+ if (args.externalId) {
83
+ const existing = await ctx.db
84
+ .query("menstrualCycles")
85
+ .withIndex("by_external_id", (idx) => idx.eq("externalId", args.externalId))
86
+ .first();
87
+ if (existing) {
88
+ await ctx.db.patch(existing._id, args);
89
+ return existing._id;
90
+ }
91
+ }
92
+
93
+ // Dedup by user + periodStartDate
94
+ const existing = await ctx.db
95
+ .query("menstrualCycles")
96
+ .withIndex("by_user_date", (idx) =>
97
+ idx.eq("userId", args.userId).eq("periodStartDate", args.periodStartDate),
98
+ )
99
+ .first();
100
+
101
+ if (existing) {
102
+ await ctx.db.patch(existing._id, args);
103
+ return existing._id;
104
+ }
105
+
106
+ return await ctx.db.insert("menstrualCycles", args);
107
+ },
108
+ });
109
+
110
+ /**
111
+ * Delete all menstrual cycle data for a user.
112
+ */
113
+ export const deleteByUser = internalMutation({
114
+ args: { userId: v.string() },
115
+ handler: async (ctx, args) => {
116
+ const records = await ctx.db
117
+ .query("menstrualCycles")
118
+ .withIndex("by_user_provider", (idx) => idx.eq("userId", args.userId))
119
+ .collect();
120
+ for (const r of records) {
121
+ await ctx.db.delete(r._id);
122
+ }
123
+ },
124
+ });