@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,383 @@
1
+ /**
2
+ * Risks Entity for OKRHub Component
3
+ *
4
+ * CRUD operations and queries for Risks.
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
+ riskPayloadValidator,
14
+ PrioritySchema,
15
+ SyncStatusSchema,
16
+ } from "../schema.js";
17
+
18
+ // ============================================================================
19
+ // LOCAL CRUD MUTATIONS
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Creates a risk locally and queues for sync
24
+ */
25
+ export const createRisk = mutation({
26
+ args: {
27
+ sourceApp: v.string(),
28
+ sourceUrl: v.optional(v.string()),
29
+ description: v.string(),
30
+ teamExternalId: v.string(),
31
+ keyResultExternalId: v.string(), // Required: Reference to key result
32
+ priority: PrioritySchema,
33
+ indicatorExternalId: v.optional(v.string()),
34
+ triggerValue: v.optional(v.number()),
35
+ triggeredIfLower: v.optional(v.boolean()),
36
+ useForecastAsTrigger: v.optional(v.boolean()),
37
+ isRed: v.optional(v.boolean()),
38
+ },
39
+ returns: v.object({
40
+ success: v.boolean(),
41
+ externalId: v.string(),
42
+ localId: v.id("risks"),
43
+ queueId: v.optional(v.id("syncQueue")),
44
+ error: v.optional(v.string()),
45
+ }),
46
+ handler: async (ctx, args) => {
47
+ const {
48
+ sourceApp,
49
+ sourceUrl,
50
+ description,
51
+ teamExternalId,
52
+ keyResultExternalId,
53
+ priority,
54
+ indicatorExternalId,
55
+ triggerValue,
56
+ triggeredIfLower,
57
+ useForecastAsTrigger,
58
+ isRed,
59
+ } = args;
60
+
61
+ try {
62
+ assertValidExternalId(teamExternalId, "teamExternalId");
63
+ assertValidExternalId(keyResultExternalId, "keyResultExternalId");
64
+ if (indicatorExternalId) {
65
+ assertValidExternalId(indicatorExternalId, "indicatorExternalId");
66
+ }
67
+
68
+ const externalId = generateExternalId(sourceApp, "risk");
69
+ const slug = generateSlug(sourceApp, description.substring(0, 30));
70
+ const now = Date.now();
71
+
72
+ const localId = await ctx.db.insert("risks", {
73
+ externalId,
74
+ description,
75
+ teamExternalId,
76
+ keyResultExternalId,
77
+ priority,
78
+ indicatorExternalId,
79
+ triggerValue,
80
+ triggeredIfLower,
81
+ useForecastAsTrigger,
82
+ isRed,
83
+ slug,
84
+ syncStatus: "pending",
85
+ createdAt: now,
86
+ });
87
+
88
+ const payload = JSON.stringify({
89
+ externalId,
90
+ description,
91
+ teamExternalId,
92
+ keyResultExternalId,
93
+ priority,
94
+ indicatorExternalId,
95
+ triggerValue,
96
+ triggeredIfLower,
97
+ useForecastAsTrigger,
98
+ isRed,
99
+ sourceUrl,
100
+ createdAt: now,
101
+ });
102
+
103
+ const queueId = await ctx.db.insert("syncQueue", {
104
+ entityType: "risk",
105
+ externalId,
106
+ payload,
107
+ status: "pending",
108
+ attempts: 0,
109
+ createdAt: now,
110
+ });
111
+
112
+ return {
113
+ success: true,
114
+ externalId,
115
+ localId,
116
+ queueId,
117
+ };
118
+ } catch (error) {
119
+ const errorMessage =
120
+ error && typeof error === "object" && "message" in error
121
+ ? (error.message as string)
122
+ : "Unknown error";
123
+
124
+ return {
125
+ success: false,
126
+ externalId: "",
127
+ localId: "" as Id<"risks">,
128
+ error: errorMessage,
129
+ };
130
+ }
131
+ },
132
+ });
133
+
134
+ // ============================================================================
135
+ // LOCAL QUERY FUNCTIONS
136
+ // ============================================================================
137
+
138
+ /**
139
+ * Gets all local risks for a key result
140
+ */
141
+ export const getRisksByKeyResult = query({
142
+ args: {
143
+ keyResultExternalId: v.string(),
144
+ },
145
+ returns: v.array(
146
+ v.object({
147
+ _id: v.id("risks"),
148
+ _creationTime: v.number(),
149
+ externalId: v.string(),
150
+ description: v.string(),
151
+ teamExternalId: v.string(),
152
+ keyResultExternalId: v.string(), // Required
153
+ priority: PrioritySchema,
154
+ indicatorExternalId: v.optional(v.string()),
155
+ triggerValue: v.optional(v.number()),
156
+ triggeredIfLower: v.optional(v.boolean()),
157
+ useForecastAsTrigger: v.optional(v.boolean()),
158
+ isRed: v.optional(v.boolean()),
159
+ slug: v.string(),
160
+ syncStatus: SyncStatusSchema,
161
+ createdAt: v.number(),
162
+ deletedAt: v.optional(v.number()),
163
+ })
164
+ ),
165
+ handler: async (ctx, args) => {
166
+ return await ctx.db
167
+ .query("risks")
168
+ .withIndex("by_key_result", (q) =>
169
+ q.eq("keyResultExternalId", args.keyResultExternalId)
170
+ )
171
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
172
+ .collect();
173
+ },
174
+ });
175
+
176
+ /**
177
+ * Gets all local risks
178
+ */
179
+ export const getAllRisks = query({
180
+ args: {},
181
+ returns: v.array(
182
+ v.object({
183
+ _id: v.id("risks"),
184
+ _creationTime: v.number(),
185
+ externalId: v.string(),
186
+ description: v.string(),
187
+ teamExternalId: v.string(),
188
+ keyResultExternalId: v.string(), // Required
189
+ priority: PrioritySchema,
190
+ indicatorExternalId: v.optional(v.string()),
191
+ triggerValue: v.optional(v.number()),
192
+ triggeredIfLower: v.optional(v.boolean()),
193
+ useForecastAsTrigger: v.optional(v.boolean()),
194
+ isRed: v.optional(v.boolean()),
195
+ slug: v.string(),
196
+ syncStatus: SyncStatusSchema,
197
+ createdAt: v.number(),
198
+ deletedAt: v.optional(v.number()),
199
+ })
200
+ ),
201
+ handler: async (ctx) => {
202
+ return await ctx.db
203
+ .query("risks")
204
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
205
+ .collect();
206
+ },
207
+ });
208
+
209
+ // ============================================================================
210
+ // UPDATE MUTATIONS
211
+ // ============================================================================
212
+
213
+ /**
214
+ * Updates a risk locally and queues for sync
215
+ * Resets syncStatus to "pending"
216
+ */
217
+ export const updateRisk = mutation({
218
+ args: {
219
+ externalId: v.string(),
220
+ description: v.optional(v.string()),
221
+ priority: v.optional(PrioritySchema),
222
+ keyResultExternalId: v.optional(v.string()),
223
+ indicatorExternalId: v.optional(v.string()),
224
+ triggerValue: v.optional(v.number()),
225
+ triggeredIfLower: v.optional(v.boolean()),
226
+ useForecastAsTrigger: v.optional(v.boolean()),
227
+ isRed: v.optional(v.boolean()),
228
+ },
229
+ returns: v.object({
230
+ success: v.boolean(),
231
+ externalId: v.string(),
232
+ queueId: v.optional(v.id("syncQueue")),
233
+ error: v.optional(v.string()),
234
+ }),
235
+ handler: async (ctx, args) => {
236
+ const {
237
+ externalId,
238
+ description,
239
+ priority,
240
+ keyResultExternalId,
241
+ indicatorExternalId,
242
+ triggerValue,
243
+ triggeredIfLower,
244
+ useForecastAsTrigger,
245
+ isRed,
246
+ } = args;
247
+
248
+ try {
249
+ // Find the risk by externalId
250
+ const risk = await ctx.db
251
+ .query("risks")
252
+ .withIndex("by_external_id", (q) => q.eq("externalId", externalId))
253
+ .first();
254
+
255
+ if (!risk) {
256
+ return {
257
+ success: false,
258
+ externalId,
259
+ error: `Risk not found: ${externalId}`,
260
+ };
261
+ }
262
+
263
+ // Validate external IDs if provided
264
+ if (keyResultExternalId) {
265
+ assertValidExternalId(keyResultExternalId, "keyResultExternalId");
266
+ }
267
+ if (indicatorExternalId) {
268
+ assertValidExternalId(indicatorExternalId, "indicatorExternalId");
269
+ }
270
+
271
+ const now = Date.now();
272
+
273
+ // Update the risk
274
+ await ctx.db.patch(risk._id, {
275
+ ...(description !== undefined && { description }),
276
+ ...(priority !== undefined && { priority }),
277
+ ...(keyResultExternalId !== undefined && { keyResultExternalId }),
278
+ ...(indicatorExternalId !== undefined && { indicatorExternalId }),
279
+ ...(triggerValue !== undefined && { triggerValue }),
280
+ ...(triggeredIfLower !== undefined && { triggeredIfLower }),
281
+ ...(useForecastAsTrigger !== undefined && { useForecastAsTrigger }),
282
+ ...(isRed !== undefined && { isRed }),
283
+ syncStatus: "pending",
284
+ });
285
+
286
+ // Create payload for sync with updated values
287
+ const updatedRisk = {
288
+ externalId,
289
+ description: description ?? risk.description,
290
+ teamExternalId: risk.teamExternalId,
291
+ priority: priority ?? risk.priority,
292
+ keyResultExternalId: keyResultExternalId ?? risk.keyResultExternalId,
293
+ indicatorExternalId: indicatorExternalId ?? risk.indicatorExternalId,
294
+ triggerValue: triggerValue ?? risk.triggerValue,
295
+ triggeredIfLower: triggeredIfLower ?? risk.triggeredIfLower,
296
+ useForecastAsTrigger: useForecastAsTrigger ?? risk.useForecastAsTrigger,
297
+ isRed: isRed ?? risk.isRed,
298
+ };
299
+
300
+ const payload = JSON.stringify(updatedRisk);
301
+
302
+ // Add to sync queue
303
+ const queueId = await ctx.db.insert("syncQueue", {
304
+ entityType: "risk",
305
+ externalId,
306
+ payload,
307
+ status: "pending",
308
+ attempts: 0,
309
+ createdAt: now,
310
+ });
311
+
312
+ return {
313
+ success: true,
314
+ externalId,
315
+ queueId,
316
+ };
317
+ } catch (error) {
318
+ const errorMessage =
319
+ error && typeof error === "object" && "message" in error
320
+ ? (error.message as string)
321
+ : "Unknown error";
322
+
323
+ return {
324
+ success: false,
325
+ externalId,
326
+ error: errorMessage,
327
+ };
328
+ }
329
+ },
330
+ });
331
+
332
+ // ============================================================================
333
+ // PUBLIC MUTATIONS - Entry points for consumers
334
+ // ============================================================================
335
+
336
+ /**
337
+ * Insert a risk into LinkHub
338
+ */
339
+ export const insertRisk = mutation({
340
+ args: {
341
+ risk: riskPayloadValidator,
342
+ },
343
+ returns: v.object({
344
+ success: v.boolean(),
345
+ externalId: v.string(),
346
+ queueId: v.optional(v.id("syncQueue")),
347
+ error: v.optional(v.string()),
348
+ }),
349
+ handler: async (ctx, args) => {
350
+ const { risk } = args;
351
+
352
+ // Validate external IDs
353
+ assertValidExternalId(risk.externalId, "risk.externalId");
354
+ assertValidExternalId(risk.teamExternalId, "risk.teamExternalId");
355
+ assertValidExternalId(
356
+ risk.keyResultExternalId,
357
+ "risk.keyResultExternalId"
358
+ );
359
+ if (risk.indicatorExternalId) {
360
+ assertValidExternalId(
361
+ risk.indicatorExternalId,
362
+ "risk.indicatorExternalId"
363
+ );
364
+ }
365
+
366
+ // Add to sync queue
367
+ const payload = JSON.stringify(risk);
368
+ const queueId = await ctx.db.insert("syncQueue", {
369
+ entityType: "risk",
370
+ externalId: risk.externalId,
371
+ payload,
372
+ status: "pending",
373
+ attempts: 0,
374
+ createdAt: Date.now(),
375
+ });
376
+
377
+ return {
378
+ success: true,
379
+ externalId: risk.externalId,
380
+ queueId,
381
+ };
382
+ },
383
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * External ID helper for OKRHub component
3
+ *
4
+ * External IDs are used to map entities between the consumer app and LinkHub.
5
+ * Format: {sourceApp}:{entityType}:{uuid}
6
+ *
7
+ * Example: "mycrm:objective:550e8400-e29b-41d4-a716-446655440000"
8
+ */
9
+
10
+ import { v } from "convex/values";
11
+
12
+ // Entity types supported by OKRHub
13
+ export const ENTITY_TYPES = [
14
+ "objective",
15
+ "keyResult",
16
+ "risk",
17
+ "initiative",
18
+ "indicator",
19
+ "indicatorValue",
20
+ "indicatorForecast",
21
+ "milestone",
22
+ "team",
23
+ "company",
24
+ "user",
25
+ ] as const;
26
+
27
+ export type EntityType = (typeof ENTITY_TYPES)[number];
28
+
29
+ // Convex validator for entity type
30
+ export const entityTypeValidator = v.union(
31
+ v.literal("objective"),
32
+ v.literal("keyResult"),
33
+ v.literal("risk"),
34
+ v.literal("initiative"),
35
+ v.literal("indicator"),
36
+ v.literal("indicatorValue"),
37
+ v.literal("indicatorForecast"),
38
+ v.literal("milestone"),
39
+ v.literal("team"),
40
+ v.literal("company"),
41
+ v.literal("user")
42
+ );
43
+
44
+ // Regex pattern for external ID validation
45
+ // Format: {sourceApp}:{entityType}:{uuid}
46
+ // - sourceApp: 2-32 lowercase alphanumeric characters or hyphens
47
+ // - entityType: one of the supported entity types
48
+ // - uuid: UUID v4 format (36 characters with hyphens)
49
+ const EXTERNAL_ID_REGEX =
50
+ /^[a-z0-9-]{2,32}:(objective|keyResult|risk|initiative|indicator|indicatorValue|indicatorForecast|milestone|team|company|user):[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
51
+
52
+ // Simpler regex for basic validation (allows any UUID-like format)
53
+ const EXTERNAL_ID_REGEX_SIMPLE =
54
+ /^[a-z0-9-]{2,32}:(objective|keyResult|risk|initiative|indicator|indicatorValue|indicatorForecast|milestone|team|company|user):[a-f0-9-]{36}$/;
55
+
56
+ /**
57
+ * Validates an external ID format
58
+ * @param id - The external ID to validate
59
+ * @returns true if the ID is valid, false otherwise
60
+ */
61
+ export function validateExternalId(id: string): boolean {
62
+ return EXTERNAL_ID_REGEX_SIMPLE.test(id);
63
+ }
64
+
65
+ /**
66
+ * Validates a source app name format
67
+ * @param sourceApp - The source app name to validate
68
+ * @returns true if valid, false otherwise
69
+ */
70
+ export function validateSourceApp(sourceApp: string): boolean {
71
+ return /^[a-z0-9-]{2,32}$/.test(sourceApp);
72
+ }
73
+
74
+ /**
75
+ * Generates a new external ID for the given source app and entity type
76
+ * @param sourceApp - The identifier of the source application (2-32 lowercase alphanumeric or hyphens)
77
+ * @param entityType - The type of entity (objective, keyResult, risk, initiative, indicator, milestone)
78
+ * @returns A new external ID in the format {sourceApp}:{entityType}:{uuid}
79
+ * @throws Error if sourceApp format is invalid
80
+ */
81
+ export function generateExternalId(
82
+ sourceApp: string,
83
+ entityType: EntityType
84
+ ): string {
85
+ if (!validateSourceApp(sourceApp)) {
86
+ throw new Error(
87
+ `Invalid sourceApp format: "${sourceApp}". Must be 2-32 lowercase alphanumeric characters or hyphens.`
88
+ );
89
+ }
90
+
91
+ const uuid = crypto.randomUUID();
92
+ return `${sourceApp}:${entityType}:${uuid}`;
93
+ }
94
+
95
+ /**
96
+ * Parsed external ID structure
97
+ */
98
+ export interface ParsedExternalId {
99
+ sourceApp: string;
100
+ entityType: EntityType;
101
+ uuid: string;
102
+ }
103
+
104
+ /**
105
+ * Parses an external ID into its components
106
+ * @param id - The external ID to parse
107
+ * @returns The parsed components, or null if the ID is invalid
108
+ */
109
+ export function parseExternalId(id: string): ParsedExternalId | null {
110
+ const match = id.match(
111
+ /^([a-z0-9-]{2,32}):(objective|keyResult|risk|initiative|indicator|indicatorValue|indicatorForecast|milestone|team|company|user):([a-f0-9-]{36})$/
112
+ );
113
+
114
+ if (!match) return null;
115
+
116
+ return {
117
+ sourceApp: match[1],
118
+ entityType: match[2] as EntityType,
119
+ uuid: match[3],
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Extracts the source app from an external ID
125
+ * @param id - The external ID
126
+ * @returns The source app, or null if invalid
127
+ */
128
+ export function extractSourceApp(id: string): string | null {
129
+ const parsed = parseExternalId(id);
130
+ return parsed?.sourceApp ?? null;
131
+ }
132
+
133
+ /**
134
+ * Extracts the entity type from an external ID
135
+ * @param id - The external ID
136
+ * @returns The entity type, or null if invalid
137
+ */
138
+ export function extractEntityType(id: string): EntityType | null {
139
+ const parsed = parseExternalId(id);
140
+ return parsed?.entityType ?? null;
141
+ }
142
+
143
+ /**
144
+ * Checks if two external IDs belong to the same source app
145
+ * @param id1 - First external ID
146
+ * @param id2 - Second external ID
147
+ * @returns true if both IDs are from the same source app
148
+ */
149
+ export function sameSourceApp(id1: string, id2: string): boolean {
150
+ const app1 = extractSourceApp(id1);
151
+ const app2 = extractSourceApp(id2);
152
+ return app1 !== null && app1 === app2;
153
+ }
154
+
155
+ /**
156
+ * Convex validator for external ID (as string with runtime validation)
157
+ * Use this in your mutation/query args
158
+ */
159
+ export const externalIdValidator = v.string();
160
+
161
+ /**
162
+ * Version information for the OKRHub component
163
+ * Used for compatibility checks with LinkHub API
164
+ */
165
+ export const OKRHUB_VERSION = "0.1.0";
166
+
167
+ /**
168
+ * Gets the current component version
169
+ */
170
+ export function getVersion(): string {
171
+ return OKRHUB_VERSION;
172
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * HMAC Signature Utilities for OKRHub Component
3
+ *
4
+ * Provides cryptographic signature functions for secure API communication
5
+ * with LinkHub's ingest endpoints.
6
+ */
7
+
8
+ import { OKRHUB_VERSION } from "../externalId.js";
9
+
10
+ /**
11
+ * Creates HMAC-SHA256 signature for payload authentication
12
+ *
13
+ * SECURITY NOTE: Uses the signing secret (not the API key) to create
14
+ * cryptographically consistent signatures that the server can verify.
15
+ */
16
+ export async function createHmacSignature(
17
+ payload: string,
18
+ signingSecret: string
19
+ ): Promise<string> {
20
+ const encoder = new TextEncoder();
21
+ const keyData = encoder.encode(signingSecret);
22
+ const messageData = encoder.encode(payload);
23
+
24
+ const cryptoKey = await crypto.subtle.importKey(
25
+ "raw",
26
+ keyData,
27
+ { name: "HMAC", hash: "SHA-256" },
28
+ false,
29
+ ["sign"]
30
+ );
31
+
32
+ const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
33
+ const hashArray = Array.from(new Uint8Array(signature));
34
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
35
+ }
36
+
37
+ /**
38
+ * Creates request headers with HMAC signature, version, and key prefix
39
+ */
40
+ export async function createRequestHeaders(
41
+ payload: string,
42
+ apiKeyPrefix: string,
43
+ signingSecret: string
44
+ ): Promise<Headers> {
45
+ const signature = await createHmacSignature(payload, signingSecret);
46
+
47
+ return new Headers({
48
+ "Content-Type": "application/json",
49
+ "X-OKRHub-Version": OKRHUB_VERSION,
50
+ "X-OKRHub-Key-Prefix": apiKeyPrefix,
51
+ "X-OKRHub-Signature": signature,
52
+ });
53
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Library utilities barrel export
3
+ */
4
+
5
+ export { createHmacSignature, createRequestHeaders } from "./hmac.js";
6
+ export { assertValidExternalId, generateSlug } from "./validation.js";
7
+ export type { IngestResponse, BatchIngestResponse } from "./types.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared Types for OKRHub Component
3
+ *
4
+ * Response types and shared interfaces used across the component.
5
+ */
6
+
7
+ /**
8
+ * Response from LinkHub's single entity ingest endpoint
9
+ */
10
+ export interface IngestResponse {
11
+ success: boolean;
12
+ externalId: string;
13
+ linkHubId?: string;
14
+ action: "create" | "update";
15
+ error?: string;
16
+ }
17
+
18
+ /**
19
+ * Response from LinkHub's batch ingest endpoint
20
+ */
21
+ export interface BatchIngestResponse {
22
+ success: boolean;
23
+ results: {
24
+ entityType: string;
25
+ externalId: string;
26
+ linkHubId?: string;
27
+ action: "create" | "update";
28
+ error?: string;
29
+ }[];
30
+ errors: string[];
31
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Validation Utilities for OKRHub Component
3
+ *
4
+ * Provides validation helpers for external IDs and slug generation.
5
+ */
6
+
7
+ import { validateExternalId } from "../externalId.js";
8
+
9
+ /**
10
+ * Validates external ID format and throws if invalid
11
+ */
12
+ export function assertValidExternalId(
13
+ externalId: string,
14
+ fieldName = "externalId"
15
+ ): void {
16
+ if (!validateExternalId(externalId)) {
17
+ throw new Error(
18
+ `Invalid ${fieldName} format: "${externalId}". ` +
19
+ `Expected format: {sourceApp}:{entityType}:{uuid}`
20
+ );
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Generates a slug from text with sourceApp prefix
26
+ * Pattern: {sourceApp}-{baseSlug}-{suffix}
27
+ */
28
+ export function generateSlug(
29
+ sourceApp: string,
30
+ text: string,
31
+ maxLength = 50
32
+ ): string {
33
+ const baseSlug = text
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, "-")
36
+ .replace(/^-|-$/g, "")
37
+ .substring(0, maxLength - sourceApp.length - 7); // Reserve space for prefix and suffix
38
+
39
+ const suffix = Math.random().toString(36).substring(2, 6);
40
+ return `${sourceApp}-${baseSlug}-${suffix}`;
41
+ }