@okrlinkhub/okrhub 0.1.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.
Files changed (148) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +579 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +593 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +704 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +72 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +1986 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +3 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/entities/batch.d.ts +121 -0
  32. package/dist/component/entities/batch.d.ts.map +1 -0
  33. package/dist/component/entities/batch.js +81 -0
  34. package/dist/component/entities/batch.js.map +1 -0
  35. package/dist/component/entities/index.d.ts +13 -0
  36. package/dist/component/entities/index.d.ts.map +1 -0
  37. package/dist/component/entities/index.js +22 -0
  38. package/dist/component/entities/index.js.map +1 -0
  39. package/dist/component/entities/indicatorForecasts.d.ts +61 -0
  40. package/dist/component/entities/indicatorForecasts.d.ts.map +1 -0
  41. package/dist/component/entities/indicatorForecasts.js +180 -0
  42. package/dist/component/entities/indicatorForecasts.js.map +1 -0
  43. package/dist/component/entities/indicatorValues.d.ts +77 -0
  44. package/dist/component/entities/indicatorValues.d.ts.map +1 -0
  45. package/dist/component/entities/indicatorValues.js +218 -0
  46. package/dist/component/entities/indicatorValues.js.map +1 -0
  47. package/dist/component/entities/indicators.d.ts +90 -0
  48. package/dist/component/entities/indicators.d.ts.map +1 -0
  49. package/dist/component/entities/indicators.js +239 -0
  50. package/dist/component/entities/indicators.js.map +1 -0
  51. package/dist/component/entities/initiatives.d.ts +103 -0
  52. package/dist/component/entities/initiatives.d.ts.map +1 -0
  53. package/dist/component/entities/initiatives.js +275 -0
  54. package/dist/component/entities/initiatives.js.map +1 -0
  55. package/dist/component/entities/keyResults.d.ts +111 -0
  56. package/dist/component/entities/keyResults.d.ts.map +1 -0
  57. package/dist/component/entities/keyResults.js +284 -0
  58. package/dist/component/entities/keyResults.js.map +1 -0
  59. package/dist/component/entities/milestones.d.ts +93 -0
  60. package/dist/component/entities/milestones.d.ts.map +1 -0
  61. package/dist/component/entities/milestones.js +249 -0
  62. package/dist/component/entities/milestones.js.map +1 -0
  63. package/dist/component/entities/objectives.d.ts +99 -0
  64. package/dist/component/entities/objectives.d.ts.map +1 -0
  65. package/dist/component/entities/objectives.js +261 -0
  66. package/dist/component/entities/objectives.js.map +1 -0
  67. package/dist/component/entities/risks.d.ts +126 -0
  68. package/dist/component/entities/risks.d.ts.map +1 -0
  69. package/dist/component/entities/risks.js +315 -0
  70. package/dist/component/entities/risks.js.map +1 -0
  71. package/dist/component/externalId.d.ts +79 -0
  72. package/dist/component/externalId.d.ts.map +1 -0
  73. package/dist/component/externalId.js +124 -0
  74. package/dist/component/externalId.js.map +1 -0
  75. package/dist/component/lib/hmac.d.ts +18 -0
  76. package/dist/component/lib/hmac.d.ts.map +1 -0
  77. package/dist/component/lib/hmac.js +35 -0
  78. package/dist/component/lib/hmac.js.map +1 -0
  79. package/dist/component/lib/index.d.ts +7 -0
  80. package/dist/component/lib/index.d.ts.map +1 -0
  81. package/dist/component/lib/index.js +6 -0
  82. package/dist/component/lib/index.js.map +1 -0
  83. package/dist/component/lib/types.d.ts +30 -0
  84. package/dist/component/lib/types.d.ts.map +1 -0
  85. package/dist/component/lib/types.js +7 -0
  86. package/dist/component/lib/types.js.map +1 -0
  87. package/dist/component/lib/validation.d.ts +15 -0
  88. package/dist/component/lib/validation.d.ts.map +1 -0
  89. package/dist/component/lib/validation.js +29 -0
  90. package/dist/component/lib/validation.js.map +1 -0
  91. package/dist/component/okrhub.d.ts +31 -0
  92. package/dist/component/okrhub.d.ts.map +1 -0
  93. package/dist/component/okrhub.js +45 -0
  94. package/dist/component/okrhub.js.map +1 -0
  95. package/dist/component/schema.d.ts +943 -0
  96. package/dist/component/schema.d.ts.map +1 -0
  97. package/dist/component/schema.js +437 -0
  98. package/dist/component/schema.js.map +1 -0
  99. package/dist/component/sync/http.d.ts +30 -0
  100. package/dist/component/sync/http.d.ts.map +1 -0
  101. package/dist/component/sync/http.js +114 -0
  102. package/dist/component/sync/http.js.map +1 -0
  103. package/dist/component/sync/index.d.ts +7 -0
  104. package/dist/component/sync/index.d.ts.map +1 -0
  105. package/dist/component/sync/index.js +7 -0
  106. package/dist/component/sync/index.js.map +1 -0
  107. package/dist/component/sync/processor.d.ts +20 -0
  108. package/dist/component/sync/processor.d.ts.map +1 -0
  109. package/dist/component/sync/processor.js +67 -0
  110. package/dist/component/sync/processor.js.map +1 -0
  111. package/dist/component/sync/queue.d.ts +40 -0
  112. package/dist/component/sync/queue.d.ts.map +1 -0
  113. package/dist/component/sync/queue.js +176 -0
  114. package/dist/component/sync/queue.js.map +1 -0
  115. package/dist/react/index.d.ts +2 -0
  116. package/dist/react/index.d.ts.map +1 -0
  117. package/dist/react/index.js +6 -0
  118. package/dist/react/index.js.map +1 -0
  119. package/package.json +117 -0
  120. package/src/client/_generated/_ignore.ts +1 -0
  121. package/src/client/index.ts +1004 -0
  122. package/src/component/_generated/api.ts +88 -0
  123. package/src/component/_generated/component.ts +2685 -0
  124. package/src/component/_generated/dataModel.ts +60 -0
  125. package/src/component/_generated/server.ts +156 -0
  126. package/src/component/convex.config.ts +3 -0
  127. package/src/component/entities/batch.ts +90 -0
  128. package/src/component/entities/index.ts +64 -0
  129. package/src/component/entities/indicatorForecasts.ts +205 -0
  130. package/src/component/entities/indicatorValues.ts +254 -0
  131. package/src/component/entities/indicators.ts +290 -0
  132. package/src/component/entities/initiatives.ts +342 -0
  133. package/src/component/entities/keyResults.ts +334 -0
  134. package/src/component/entities/milestones.ts +296 -0
  135. package/src/component/entities/objectives.ts +294 -0
  136. package/src/component/entities/risks.ts +383 -0
  137. package/src/component/externalId.ts +172 -0
  138. package/src/component/lib/hmac.ts +53 -0
  139. package/src/component/lib/index.ts +7 -0
  140. package/src/component/lib/types.ts +31 -0
  141. package/src/component/lib/validation.ts +41 -0
  142. package/src/component/okrhub.ts +110 -0
  143. package/src/component/schema.ts +574 -0
  144. package/src/component/sync/http.ts +138 -0
  145. package/src/component/sync/index.ts +11 -0
  146. package/src/component/sync/processor.ts +77 -0
  147. package/src/component/sync/queue.ts +201 -0
  148. package/src/react/index.ts +7 -0
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Initiatives Entity for OKRHub Component
3
+ *
4
+ * CRUD operations and queries for Initiatives.
5
+ */
6
+
7
+ import { v } from "convex/values";
8
+ import { mutation, query } from "../_generated/server.js";
9
+ import type { Id } from "../_generated/dataModel.js";
10
+ import { generateExternalId } from "../externalId.js";
11
+ import { assertValidExternalId, generateSlug } from "../lib/validation.js";
12
+ import {
13
+ initiativePayloadValidator,
14
+ PrioritySchema,
15
+ InitiativeStatusSchema,
16
+ SyncStatusSchema,
17
+ } from "../schema.js";
18
+
19
+ // ============================================================================
20
+ // LOCAL CRUD MUTATIONS
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Creates an initiative locally and queues for sync
25
+ * Note: relativeImpact/overallImpact/isNew are set by linkhub based on priority
26
+ */
27
+ export const createInitiative = mutation({
28
+ args: {
29
+ sourceApp: v.string(),
30
+ sourceUrl: v.optional(v.string()),
31
+ description: v.string(),
32
+ teamExternalId: v.string(),
33
+ riskExternalId: v.string(), // Required: Reference to risk
34
+ assigneeExternalId: v.string(),
35
+ createdByExternalId: v.string(),
36
+ status: v.optional(InitiativeStatusSchema), // Optional in input, default ON_TIME
37
+ priority: PrioritySchema,
38
+ finishedAt: v.optional(v.number()),
39
+ },
40
+ returns: v.object({
41
+ success: v.boolean(),
42
+ externalId: v.string(),
43
+ localId: v.id("initiatives"),
44
+ queueId: v.optional(v.id("syncQueue")),
45
+ error: v.optional(v.string()),
46
+ }),
47
+ handler: async (ctx, args) => {
48
+ const {
49
+ sourceApp,
50
+ sourceUrl,
51
+ description,
52
+ teamExternalId,
53
+ riskExternalId,
54
+ assigneeExternalId,
55
+ createdByExternalId,
56
+ status,
57
+ priority,
58
+ finishedAt,
59
+ } = args;
60
+
61
+ try {
62
+ assertValidExternalId(teamExternalId, "teamExternalId");
63
+ assertValidExternalId(assigneeExternalId, "assigneeExternalId");
64
+ assertValidExternalId(createdByExternalId, "createdByExternalId");
65
+ assertValidExternalId(riskExternalId, "riskExternalId");
66
+
67
+ const externalId = generateExternalId(sourceApp, "initiative");
68
+ const slug = generateSlug(sourceApp, description.substring(0, 30));
69
+ const now = Date.now();
70
+
71
+ const localId = await ctx.db.insert("initiatives", {
72
+ externalId,
73
+ description,
74
+ teamExternalId,
75
+ riskExternalId,
76
+ assigneeExternalId,
77
+ createdByExternalId,
78
+ status: status ?? "ON_TIME", // Default to ON_TIME
79
+ priority,
80
+ finishedAt,
81
+ slug,
82
+ syncStatus: "pending",
83
+ createdAt: now,
84
+ });
85
+
86
+ const payload = JSON.stringify({
87
+ externalId,
88
+ description,
89
+ teamExternalId,
90
+ riskExternalId,
91
+ assigneeExternalId,
92
+ createdByExternalId,
93
+ status: status ?? "ON_TIME", // Default to ON_TIME
94
+ priority,
95
+ finishedAt,
96
+ sourceUrl,
97
+ createdAt: now,
98
+ });
99
+
100
+ const queueId = await ctx.db.insert("syncQueue", {
101
+ entityType: "initiative",
102
+ externalId,
103
+ payload,
104
+ status: "pending",
105
+ attempts: 0,
106
+ createdAt: now,
107
+ });
108
+
109
+ return {
110
+ success: true,
111
+ externalId,
112
+ localId,
113
+ queueId,
114
+ };
115
+ } catch (error) {
116
+ const errorMessage =
117
+ error && typeof error === "object" && "message" in error
118
+ ? (error.message as string)
119
+ : "Unknown error";
120
+
121
+ return {
122
+ success: false,
123
+ externalId: "",
124
+ localId: "" as Id<"initiatives">,
125
+ error: errorMessage,
126
+ };
127
+ }
128
+ },
129
+ });
130
+
131
+ // ============================================================================
132
+ // LOCAL QUERY FUNCTIONS
133
+ // ============================================================================
134
+
135
+ /**
136
+ * Gets all local initiatives
137
+ */
138
+ export const getAllInitiatives = query({
139
+ args: {},
140
+ returns: v.array(
141
+ v.object({
142
+ _id: v.id("initiatives"),
143
+ _creationTime: v.number(),
144
+ externalId: v.string(),
145
+ description: v.string(),
146
+ teamExternalId: v.string(),
147
+ riskExternalId: v.string(), // Required
148
+ assigneeExternalId: v.string(),
149
+ createdByExternalId: v.string(),
150
+ status: InitiativeStatusSchema, // Required
151
+ priority: PrioritySchema,
152
+ finishedAt: v.optional(v.number()),
153
+ slug: v.string(),
154
+ syncStatus: SyncStatusSchema,
155
+ createdAt: v.number(),
156
+ updatedAt: v.optional(v.number()),
157
+ deletedAt: v.optional(v.number()),
158
+ })
159
+ ),
160
+ handler: async (ctx) => {
161
+ return await ctx.db
162
+ .query("initiatives")
163
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
164
+ .collect();
165
+ },
166
+ });
167
+
168
+ // ============================================================================
169
+ // UPDATE MUTATIONS
170
+ // ============================================================================
171
+
172
+ /**
173
+ * Updates an initiative locally and queues for sync
174
+ * Resets syncStatus to "pending"
175
+ */
176
+ export const updateInitiative = mutation({
177
+ args: {
178
+ externalId: v.string(),
179
+ description: v.optional(v.string()),
180
+ riskExternalId: v.optional(v.string()),
181
+ assigneeExternalId: v.optional(v.string()),
182
+ status: v.optional(InitiativeStatusSchema),
183
+ priority: v.optional(PrioritySchema),
184
+ finishedAt: v.optional(v.number()),
185
+ },
186
+ returns: v.object({
187
+ success: v.boolean(),
188
+ externalId: v.string(),
189
+ queueId: v.optional(v.id("syncQueue")),
190
+ error: v.optional(v.string()),
191
+ }),
192
+ handler: async (ctx, args) => {
193
+ const {
194
+ externalId,
195
+ description,
196
+ riskExternalId,
197
+ assigneeExternalId,
198
+ status,
199
+ priority,
200
+ finishedAt,
201
+ } = args;
202
+
203
+ try {
204
+ // Find the initiative by externalId
205
+ const initiative = await ctx.db
206
+ .query("initiatives")
207
+ .withIndex("by_external_id", (q) => q.eq("externalId", externalId))
208
+ .first();
209
+
210
+ if (!initiative) {
211
+ return {
212
+ success: false,
213
+ externalId,
214
+ error: `Initiative not found: ${externalId}`,
215
+ };
216
+ }
217
+
218
+ // Validate external IDs if provided
219
+ if (riskExternalId) {
220
+ assertValidExternalId(riskExternalId, "riskExternalId");
221
+ }
222
+ if (assigneeExternalId) {
223
+ assertValidExternalId(assigneeExternalId, "assigneeExternalId");
224
+ }
225
+
226
+ const now = Date.now();
227
+
228
+ // Update the initiative
229
+ await ctx.db.patch(initiative._id, {
230
+ ...(description !== undefined && { description }),
231
+ ...(riskExternalId !== undefined && { riskExternalId }),
232
+ ...(assigneeExternalId !== undefined && { assigneeExternalId }),
233
+ ...(status !== undefined && { status }),
234
+ ...(priority !== undefined && { priority }),
235
+ ...(finishedAt !== undefined && { finishedAt }),
236
+ syncStatus: "pending",
237
+ updatedAt: now,
238
+ });
239
+
240
+ // Create payload for sync with updated values
241
+ const updatedInitiative = {
242
+ externalId,
243
+ description: description ?? initiative.description,
244
+ teamExternalId: initiative.teamExternalId,
245
+ riskExternalId: riskExternalId ?? initiative.riskExternalId,
246
+ assigneeExternalId: assigneeExternalId ?? initiative.assigneeExternalId,
247
+ createdByExternalId: initiative.createdByExternalId,
248
+ status: status ?? initiative.status,
249
+ priority: priority ?? initiative.priority,
250
+ finishedAt: finishedAt ?? initiative.finishedAt,
251
+ updatedAt: now,
252
+ };
253
+
254
+ const payload = JSON.stringify(updatedInitiative);
255
+
256
+ // Add to sync queue
257
+ const queueId = await ctx.db.insert("syncQueue", {
258
+ entityType: "initiative",
259
+ externalId,
260
+ payload,
261
+ status: "pending",
262
+ attempts: 0,
263
+ createdAt: now,
264
+ });
265
+
266
+ return {
267
+ success: true,
268
+ externalId,
269
+ queueId,
270
+ };
271
+ } catch (error) {
272
+ const errorMessage =
273
+ error && typeof error === "object" && "message" in error
274
+ ? (error.message as string)
275
+ : "Unknown error";
276
+
277
+ return {
278
+ success: false,
279
+ externalId,
280
+ error: errorMessage,
281
+ };
282
+ }
283
+ },
284
+ });
285
+
286
+ // ============================================================================
287
+ // PUBLIC MUTATIONS - Entry points for consumers
288
+ // ============================================================================
289
+
290
+ /**
291
+ * Insert an initiative into LinkHub
292
+ */
293
+ export const insertInitiative = mutation({
294
+ args: {
295
+ initiative: initiativePayloadValidator,
296
+ },
297
+ returns: v.object({
298
+ success: v.boolean(),
299
+ externalId: v.string(),
300
+ queueId: v.optional(v.id("syncQueue")),
301
+ error: v.optional(v.string()),
302
+ }),
303
+ handler: async (ctx, args) => {
304
+ const { initiative } = args;
305
+
306
+ // Validate external IDs
307
+ assertValidExternalId(initiative.externalId, "initiative.externalId");
308
+ assertValidExternalId(
309
+ initiative.teamExternalId,
310
+ "initiative.teamExternalId"
311
+ );
312
+ assertValidExternalId(
313
+ initiative.assigneeExternalId,
314
+ "initiative.assigneeExternalId"
315
+ );
316
+ assertValidExternalId(
317
+ initiative.createdByExternalId,
318
+ "initiative.createdByExternalId"
319
+ );
320
+ assertValidExternalId(
321
+ initiative.riskExternalId,
322
+ "initiative.riskExternalId"
323
+ );
324
+
325
+ // Add to sync queue
326
+ const payload = JSON.stringify(initiative);
327
+ const queueId = await ctx.db.insert("syncQueue", {
328
+ entityType: "initiative",
329
+ externalId: initiative.externalId,
330
+ payload,
331
+ status: "pending",
332
+ attempts: 0,
333
+ createdAt: Date.now(),
334
+ });
335
+
336
+ return {
337
+ success: true,
338
+ externalId: initiative.externalId,
339
+ queueId,
340
+ };
341
+ },
342
+ });
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Key Results Entity for OKRHub Component
3
+ *
4
+ * CRUD operations and queries for Key Results.
5
+ */
6
+
7
+ import { v } from "convex/values";
8
+ import { mutation, query } from "../_generated/server.js";
9
+ import type { Id } from "../_generated/dataModel.js";
10
+ import { generateExternalId } from "../externalId.js";
11
+ import { assertValidExternalId, generateSlug } from "../lib/validation.js";
12
+ import { keyResultPayloadValidator, SyncStatusSchema } from "../schema.js";
13
+
14
+ // ============================================================================
15
+ // LOCAL CRUD MUTATIONS
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Creates a key result locally and queues for sync
20
+ * Note: weight is always set to 0, managed only by linkhub
21
+ */
22
+ export const createKeyResult = mutation({
23
+ args: {
24
+ sourceApp: v.string(),
25
+ sourceUrl: v.optional(v.string()),
26
+ objectiveExternalId: v.string(), // Required: Reference to objective
27
+ indicatorExternalId: v.string(),
28
+ teamExternalId: v.string(),
29
+ forecastValue: v.optional(v.number()),
30
+ targetValue: v.optional(v.number()),
31
+ },
32
+ returns: v.object({
33
+ success: v.boolean(),
34
+ externalId: v.string(),
35
+ localId: v.id("keyResults"),
36
+ queueId: v.optional(v.id("syncQueue")),
37
+ error: v.optional(v.string()),
38
+ }),
39
+ handler: async (ctx, args) => {
40
+ const {
41
+ sourceApp,
42
+ sourceUrl,
43
+ objectiveExternalId,
44
+ indicatorExternalId,
45
+ teamExternalId,
46
+ forecastValue,
47
+ targetValue,
48
+ } = args;
49
+
50
+ try {
51
+ // Validate external IDs
52
+ assertValidExternalId(teamExternalId, "teamExternalId");
53
+ assertValidExternalId(indicatorExternalId, "indicatorExternalId");
54
+ assertValidExternalId(objectiveExternalId, "objectiveExternalId");
55
+
56
+ const externalId = generateExternalId(sourceApp, "keyResult");
57
+ const slug = generateSlug(sourceApp, `kr-${sourceApp}`);
58
+ const now = Date.now();
59
+
60
+ // Save locally
61
+ const localId = await ctx.db.insert("keyResults", {
62
+ externalId,
63
+ objectiveExternalId,
64
+ indicatorExternalId,
65
+ teamExternalId,
66
+ forecastValue,
67
+ targetValue,
68
+ slug,
69
+ syncStatus: "pending",
70
+ createdAt: now,
71
+ });
72
+
73
+ // Create payload for sync (weight=0 always)
74
+ const payload = JSON.stringify({
75
+ externalId,
76
+ objectiveExternalId,
77
+ indicatorExternalId,
78
+ teamExternalId,
79
+ weight: 0, // Always 0, managed by linkhub
80
+ forecastValue,
81
+ targetValue,
82
+ sourceUrl,
83
+ createdAt: now,
84
+ });
85
+
86
+ const queueId = await ctx.db.insert("syncQueue", {
87
+ entityType: "keyResult",
88
+ externalId,
89
+ payload,
90
+ status: "pending",
91
+ attempts: 0,
92
+ createdAt: now,
93
+ });
94
+
95
+ return {
96
+ success: true,
97
+ externalId,
98
+ localId,
99
+ queueId,
100
+ };
101
+ } catch (error) {
102
+ const errorMessage =
103
+ error && typeof error === "object" && "message" in error
104
+ ? (error.message as string)
105
+ : "Unknown error";
106
+
107
+ return {
108
+ success: false,
109
+ externalId: "",
110
+ localId: "" as Id<"keyResults">,
111
+ error: errorMessage,
112
+ };
113
+ }
114
+ },
115
+ });
116
+
117
+ // ============================================================================
118
+ // LOCAL QUERY FUNCTIONS
119
+ // ============================================================================
120
+
121
+ /**
122
+ * Gets all local key results for an objective
123
+ */
124
+ export const getKeyResultsByObjective = query({
125
+ args: {
126
+ objectiveExternalId: v.string(),
127
+ },
128
+ returns: v.array(
129
+ v.object({
130
+ _id: v.id("keyResults"),
131
+ _creationTime: v.number(),
132
+ externalId: v.string(),
133
+ objectiveExternalId: v.string(), // Required
134
+ indicatorExternalId: v.string(),
135
+ teamExternalId: v.string(),
136
+ forecastValue: v.optional(v.number()),
137
+ targetValue: v.optional(v.number()),
138
+ slug: v.string(),
139
+ syncStatus: SyncStatusSchema,
140
+ createdAt: v.number(),
141
+ updatedAt: v.optional(v.number()),
142
+ deletedAt: v.optional(v.number()),
143
+ })
144
+ ),
145
+ handler: async (ctx, args) => {
146
+ return await ctx.db
147
+ .query("keyResults")
148
+ .withIndex("by_objective", (q) =>
149
+ q.eq("objectiveExternalId", args.objectiveExternalId)
150
+ )
151
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
152
+ .collect();
153
+ },
154
+ });
155
+
156
+ /**
157
+ * Gets all local key results
158
+ */
159
+ export const getAllKeyResults = query({
160
+ args: {},
161
+ returns: v.array(
162
+ v.object({
163
+ _id: v.id("keyResults"),
164
+ _creationTime: v.number(),
165
+ externalId: v.string(),
166
+ objectiveExternalId: v.string(), // Required
167
+ indicatorExternalId: v.string(),
168
+ teamExternalId: v.string(),
169
+ forecastValue: v.optional(v.number()),
170
+ targetValue: v.optional(v.number()),
171
+ slug: v.string(),
172
+ syncStatus: SyncStatusSchema,
173
+ createdAt: v.number(),
174
+ updatedAt: v.optional(v.number()),
175
+ deletedAt: v.optional(v.number()),
176
+ })
177
+ ),
178
+ handler: async (ctx) => {
179
+ return await ctx.db
180
+ .query("keyResults")
181
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
182
+ .collect();
183
+ },
184
+ });
185
+
186
+ // ============================================================================
187
+ // UPDATE MUTATIONS
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Updates a key result locally and queues for sync
192
+ * Resets syncStatus to "pending"
193
+ */
194
+ export const updateKeyResult = mutation({
195
+ args: {
196
+ externalId: v.string(),
197
+ objectiveExternalId: v.optional(v.string()),
198
+ forecastValue: v.optional(v.number()),
199
+ targetValue: v.optional(v.number()),
200
+ },
201
+ returns: v.object({
202
+ success: v.boolean(),
203
+ externalId: v.string(),
204
+ queueId: v.optional(v.id("syncQueue")),
205
+ error: v.optional(v.string()),
206
+ }),
207
+ handler: async (ctx, args) => {
208
+ const { externalId, objectiveExternalId, forecastValue, targetValue } = args;
209
+
210
+ try {
211
+ // Find the key result by externalId
212
+ const keyResult = await ctx.db
213
+ .query("keyResults")
214
+ .withIndex("by_external_id", (q) => q.eq("externalId", externalId))
215
+ .first();
216
+
217
+ if (!keyResult) {
218
+ return {
219
+ success: false,
220
+ externalId,
221
+ error: `Key result not found: ${externalId}`,
222
+ };
223
+ }
224
+
225
+ // Validate objectiveExternalId if provided
226
+ if (objectiveExternalId) {
227
+ assertValidExternalId(objectiveExternalId, "objectiveExternalId");
228
+ }
229
+
230
+ const now = Date.now();
231
+
232
+ // Update the key result
233
+ await ctx.db.patch(keyResult._id, {
234
+ ...(objectiveExternalId !== undefined && { objectiveExternalId }),
235
+ ...(forecastValue !== undefined && { forecastValue }),
236
+ ...(targetValue !== undefined && { targetValue }),
237
+ syncStatus: "pending",
238
+ updatedAt: now,
239
+ });
240
+
241
+ // Create payload for sync with updated values
242
+ const updatedKeyResult = {
243
+ externalId,
244
+ objectiveExternalId: objectiveExternalId ?? keyResult.objectiveExternalId,
245
+ indicatorExternalId: keyResult.indicatorExternalId,
246
+ teamExternalId: keyResult.teamExternalId,
247
+ weight: 0, // Always 0, managed by LinkHub
248
+ forecastValue: forecastValue ?? keyResult.forecastValue,
249
+ targetValue: targetValue ?? keyResult.targetValue,
250
+ updatedAt: now,
251
+ };
252
+
253
+ const payload = JSON.stringify(updatedKeyResult);
254
+
255
+ // Add to sync queue
256
+ const queueId = await ctx.db.insert("syncQueue", {
257
+ entityType: "keyResult",
258
+ externalId,
259
+ payload,
260
+ status: "pending",
261
+ attempts: 0,
262
+ createdAt: now,
263
+ });
264
+
265
+ return {
266
+ success: true,
267
+ externalId,
268
+ queueId,
269
+ };
270
+ } catch (error) {
271
+ const errorMessage =
272
+ error && typeof error === "object" && "message" in error
273
+ ? (error.message as string)
274
+ : "Unknown error";
275
+
276
+ return {
277
+ success: false,
278
+ externalId,
279
+ error: errorMessage,
280
+ };
281
+ }
282
+ },
283
+ });
284
+
285
+ // ============================================================================
286
+ // PUBLIC MUTATIONS - Entry points for consumers
287
+ // ============================================================================
288
+
289
+ /**
290
+ * Insert a key result into LinkHub
291
+ */
292
+ export const insertKeyResult = mutation({
293
+ args: {
294
+ keyResult: keyResultPayloadValidator,
295
+ },
296
+ returns: v.object({
297
+ success: v.boolean(),
298
+ externalId: v.string(),
299
+ queueId: v.optional(v.id("syncQueue")),
300
+ error: v.optional(v.string()),
301
+ }),
302
+ handler: async (ctx, args) => {
303
+ const { keyResult } = args;
304
+
305
+ // Validate external IDs
306
+ assertValidExternalId(keyResult.externalId, "keyResult.externalId");
307
+ assertValidExternalId(keyResult.teamExternalId, "keyResult.teamExternalId");
308
+ assertValidExternalId(
309
+ keyResult.indicatorExternalId,
310
+ "keyResult.indicatorExternalId"
311
+ );
312
+ assertValidExternalId(
313
+ keyResult.objectiveExternalId,
314
+ "keyResult.objectiveExternalId"
315
+ );
316
+
317
+ // Add to sync queue
318
+ const payload = JSON.stringify(keyResult);
319
+ const queueId = await ctx.db.insert("syncQueue", {
320
+ entityType: "keyResult",
321
+ externalId: keyResult.externalId,
322
+ payload,
323
+ status: "pending",
324
+ attempts: 0,
325
+ createdAt: Date.now(),
326
+ });
327
+
328
+ return {
329
+ success: true,
330
+ externalId: keyResult.externalId,
331
+ queueId,
332
+ };
333
+ },
334
+ });