@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,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
|
+
});
|