@l4yercak3/cli 1.2.16 → 1.2.19
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/.claude/settings.local.json +3 -1
- package/docs/CRM-PIPELINES-SEQUENCES-SPEC.md +429 -0
- package/docs/INTEGRATION_PATHS_ARCHITECTURE.md +1543 -0
- package/package.json +1 -1
- package/src/commands/login.js +26 -7
- package/src/commands/spread.js +251 -10
- package/src/detectors/database-detector.js +245 -0
- package/src/detectors/expo-detector.js +4 -4
- package/src/detectors/index.js +17 -4
- package/src/generators/api-only/client.js +683 -0
- package/src/generators/api-only/index.js +96 -0
- package/src/generators/api-only/types.js +618 -0
- package/src/generators/api-only/webhooks.js +377 -0
- package/src/generators/env-generator.js +23 -8
- package/src/generators/expo-auth-generator.js +1009 -0
- package/src/generators/index.js +88 -2
- package/src/generators/mcp-guide-generator.js +256 -0
- package/src/generators/quickstart/components/index.js +1699 -0
- package/src/generators/quickstart/components-mobile/index.js +1440 -0
- package/src/generators/quickstart/database/convex.js +1257 -0
- package/src/generators/quickstart/database/index.js +34 -0
- package/src/generators/quickstart/database/supabase.js +1132 -0
- package/src/generators/quickstart/hooks/index.js +1065 -0
- package/src/generators/quickstart/index.js +177 -0
- package/src/generators/quickstart/pages/index.js +1466 -0
- package/src/generators/quickstart/screens/index.js +1498 -0
- package/src/mcp/registry/domains/benefits.js +798 -0
- package/src/mcp/registry/index.js +2 -0
- package/tests/database-detector.test.js +221 -0
- package/tests/expo-detector.test.js +3 -4
- package/tests/generators-index.test.js +215 -3
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convex Database Generator
|
|
3
|
+
* Generates Convex schema, queries, and mutations for L4YERCAK3 integration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { ensureDir, writeFileWithBackup, checkFileOverwrite } = require('../../../utils/file-utils');
|
|
8
|
+
|
|
9
|
+
class ConvexGenerator {
|
|
10
|
+
/**
|
|
11
|
+
* Generate Convex database files
|
|
12
|
+
* @param {Object} options - Generation options
|
|
13
|
+
* @returns {Promise<Object>} - Generated file paths
|
|
14
|
+
*/
|
|
15
|
+
async generate(options) {
|
|
16
|
+
const { projectPath, features = [] } = options;
|
|
17
|
+
|
|
18
|
+
const results = {
|
|
19
|
+
schema: null,
|
|
20
|
+
objects: null,
|
|
21
|
+
frontendUsers: null,
|
|
22
|
+
sync: null,
|
|
23
|
+
http: null,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const convexDir = path.join(projectPath, 'convex');
|
|
27
|
+
ensureDir(convexDir);
|
|
28
|
+
|
|
29
|
+
// Generate schema.ts
|
|
30
|
+
results.schema = await this.generateSchema(convexDir, features);
|
|
31
|
+
|
|
32
|
+
// Generate objects.ts (queries/mutations for universal objects)
|
|
33
|
+
results.objects = await this.generateObjects(convexDir);
|
|
34
|
+
|
|
35
|
+
// Generate frontendUsers.ts (auth-related queries/mutations)
|
|
36
|
+
if (features.includes('oauth')) {
|
|
37
|
+
results.frontendUsers = await this.generateFrontendUsers(convexDir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Generate sync.ts (L4YERCAK3 sync logic)
|
|
41
|
+
results.sync = await this.generateSync(convexDir);
|
|
42
|
+
|
|
43
|
+
// Generate http.ts (webhook endpoints)
|
|
44
|
+
results.http = await this.generateHttp(convexDir);
|
|
45
|
+
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async generateSchema(convexDir, _features) {
|
|
50
|
+
const outputPath = path.join(convexDir, 'schema.ts');
|
|
51
|
+
|
|
52
|
+
const action = await checkFileOverwrite(outputPath);
|
|
53
|
+
if (action === 'skip') {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const content = `/**
|
|
58
|
+
* Convex Schema for L4YERCAK3 Integration
|
|
59
|
+
* Auto-generated by @l4yercak3/cli
|
|
60
|
+
*
|
|
61
|
+
* This schema follows the ontology-first pattern, mirroring
|
|
62
|
+
* L4YERCAK3's universal object structure for seamless sync.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
66
|
+
import { v } from "convex/values";
|
|
67
|
+
|
|
68
|
+
export default defineSchema({
|
|
69
|
+
// ============================================
|
|
70
|
+
// ONTOLOGY: Universal object storage
|
|
71
|
+
// ============================================
|
|
72
|
+
|
|
73
|
+
objects: defineTable({
|
|
74
|
+
// L4YERCAK3 sync
|
|
75
|
+
l4yercak3Id: v.optional(v.string()),
|
|
76
|
+
organizationId: v.string(),
|
|
77
|
+
|
|
78
|
+
// Core fields (all objects have these)
|
|
79
|
+
type: v.string(), // "contact", "event", "form", "product", "order", etc.
|
|
80
|
+
subtype: v.optional(v.string()), // Type-specific classification
|
|
81
|
+
name: v.string(),
|
|
82
|
+
status: v.string(),
|
|
83
|
+
|
|
84
|
+
// Type-specific data stored as JSON
|
|
85
|
+
customProperties: v.any(),
|
|
86
|
+
|
|
87
|
+
// Sync tracking
|
|
88
|
+
syncStatus: v.union(
|
|
89
|
+
v.literal("synced"),
|
|
90
|
+
v.literal("pending_push"),
|
|
91
|
+
v.literal("pending_pull"),
|
|
92
|
+
v.literal("conflict"),
|
|
93
|
+
v.literal("local_only")
|
|
94
|
+
),
|
|
95
|
+
syncedAt: v.optional(v.number()),
|
|
96
|
+
localVersion: v.number(),
|
|
97
|
+
remoteVersion: v.optional(v.number()),
|
|
98
|
+
|
|
99
|
+
// Timestamps
|
|
100
|
+
createdAt: v.number(),
|
|
101
|
+
updatedAt: v.number(),
|
|
102
|
+
deletedAt: v.optional(v.number()), // Soft delete
|
|
103
|
+
})
|
|
104
|
+
.index("by_l4yercak3_id", ["l4yercak3Id"])
|
|
105
|
+
.index("by_type", ["type"])
|
|
106
|
+
.index("by_type_status", ["type", "status"])
|
|
107
|
+
.index("by_type_subtype", ["type", "subtype"])
|
|
108
|
+
.index("by_sync_status", ["syncStatus"])
|
|
109
|
+
.index("by_organization", ["organizationId"])
|
|
110
|
+
.index("by_updated", ["updatedAt"]),
|
|
111
|
+
|
|
112
|
+
// Relationships between objects
|
|
113
|
+
objectLinks: defineTable({
|
|
114
|
+
l4yercak3Id: v.optional(v.string()),
|
|
115
|
+
fromObjectId: v.id("objects"),
|
|
116
|
+
toObjectId: v.id("objects"),
|
|
117
|
+
linkType: v.string(), // "attendee", "sponsor", "submission", etc.
|
|
118
|
+
metadata: v.optional(v.any()),
|
|
119
|
+
syncStatus: v.string(),
|
|
120
|
+
createdAt: v.number(),
|
|
121
|
+
})
|
|
122
|
+
.index("by_from", ["fromObjectId"])
|
|
123
|
+
.index("by_to", ["toObjectId"])
|
|
124
|
+
.index("by_from_type", ["fromObjectId", "linkType"])
|
|
125
|
+
.index("by_to_type", ["toObjectId", "linkType"]),
|
|
126
|
+
|
|
127
|
+
// ============================================
|
|
128
|
+
// AUTHENTICATION: Local user management
|
|
129
|
+
// ============================================
|
|
130
|
+
|
|
131
|
+
frontendUsers: defineTable({
|
|
132
|
+
// L4YERCAK3 sync - users become CRM contacts
|
|
133
|
+
l4yercak3ContactId: v.optional(v.string()),
|
|
134
|
+
l4yercak3FrontendUserId: v.optional(v.string()),
|
|
135
|
+
organizationId: v.string(),
|
|
136
|
+
|
|
137
|
+
// Core identity
|
|
138
|
+
email: v.string(),
|
|
139
|
+
emailVerified: v.boolean(),
|
|
140
|
+
name: v.optional(v.string()),
|
|
141
|
+
firstName: v.optional(v.string()),
|
|
142
|
+
lastName: v.optional(v.string()),
|
|
143
|
+
image: v.optional(v.string()),
|
|
144
|
+
phone: v.optional(v.string()),
|
|
145
|
+
|
|
146
|
+
// Local auth (stored securely, never synced)
|
|
147
|
+
passwordHash: v.optional(v.string()),
|
|
148
|
+
|
|
149
|
+
// OAuth accounts (stored locally)
|
|
150
|
+
oauthAccounts: v.array(
|
|
151
|
+
v.object({
|
|
152
|
+
provider: v.string(),
|
|
153
|
+
providerAccountId: v.string(),
|
|
154
|
+
accessToken: v.optional(v.string()),
|
|
155
|
+
refreshToken: v.optional(v.string()),
|
|
156
|
+
expiresAt: v.optional(v.number()),
|
|
157
|
+
scope: v.optional(v.string()),
|
|
158
|
+
})
|
|
159
|
+
),
|
|
160
|
+
|
|
161
|
+
// App-specific
|
|
162
|
+
role: v.string(), // "user", "admin", "moderator"
|
|
163
|
+
preferences: v.optional(
|
|
164
|
+
v.object({
|
|
165
|
+
language: v.optional(v.string()),
|
|
166
|
+
timezone: v.optional(v.string()),
|
|
167
|
+
theme: v.optional(v.string()),
|
|
168
|
+
emailNotifications: v.optional(v.boolean()),
|
|
169
|
+
})
|
|
170
|
+
),
|
|
171
|
+
|
|
172
|
+
// Sync
|
|
173
|
+
syncStatus: v.string(),
|
|
174
|
+
syncedAt: v.optional(v.number()),
|
|
175
|
+
|
|
176
|
+
// Timestamps
|
|
177
|
+
createdAt: v.number(),
|
|
178
|
+
updatedAt: v.number(),
|
|
179
|
+
lastLoginAt: v.optional(v.number()),
|
|
180
|
+
})
|
|
181
|
+
.index("by_email", ["email"])
|
|
182
|
+
.index("by_l4yercak3_contact", ["l4yercak3ContactId"])
|
|
183
|
+
.index("by_organization", ["organizationId"]),
|
|
184
|
+
|
|
185
|
+
// Sessions for auth
|
|
186
|
+
sessions: defineTable({
|
|
187
|
+
userId: v.id("frontendUsers"),
|
|
188
|
+
sessionToken: v.string(),
|
|
189
|
+
expiresAt: v.number(),
|
|
190
|
+
userAgent: v.optional(v.string()),
|
|
191
|
+
ipAddress: v.optional(v.string()),
|
|
192
|
+
createdAt: v.number(),
|
|
193
|
+
})
|
|
194
|
+
.index("by_token", ["sessionToken"])
|
|
195
|
+
.index("by_user", ["userId"]),
|
|
196
|
+
|
|
197
|
+
// ============================================
|
|
198
|
+
// STRIPE: Local payment handling
|
|
199
|
+
// ============================================
|
|
200
|
+
|
|
201
|
+
stripeCustomers: defineTable({
|
|
202
|
+
frontendUserId: v.id("frontendUsers"),
|
|
203
|
+
stripeCustomerId: v.string(),
|
|
204
|
+
l4yercak3ContactId: v.optional(v.string()),
|
|
205
|
+
email: v.string(),
|
|
206
|
+
name: v.optional(v.string()),
|
|
207
|
+
defaultPaymentMethodId: v.optional(v.string()),
|
|
208
|
+
syncStatus: v.string(),
|
|
209
|
+
createdAt: v.number(),
|
|
210
|
+
})
|
|
211
|
+
.index("by_stripe_id", ["stripeCustomerId"])
|
|
212
|
+
.index("by_user", ["frontendUserId"]),
|
|
213
|
+
|
|
214
|
+
stripePayments: defineTable({
|
|
215
|
+
stripePaymentIntentId: v.string(),
|
|
216
|
+
stripeCustomerId: v.optional(v.string()),
|
|
217
|
+
frontendUserId: v.optional(v.id("frontendUsers")),
|
|
218
|
+
|
|
219
|
+
// Payment details
|
|
220
|
+
amount: v.number(),
|
|
221
|
+
currency: v.string(),
|
|
222
|
+
status: v.string(),
|
|
223
|
+
paymentMethod: v.optional(v.string()),
|
|
224
|
+
|
|
225
|
+
// What was purchased
|
|
226
|
+
metadata: v.object({
|
|
227
|
+
type: v.string(), // "event_ticket", "product", "subscription"
|
|
228
|
+
items: v.array(
|
|
229
|
+
v.object({
|
|
230
|
+
objectId: v.optional(v.id("objects")),
|
|
231
|
+
l4yercak3ProductId: v.optional(v.string()),
|
|
232
|
+
name: v.string(),
|
|
233
|
+
quantity: v.number(),
|
|
234
|
+
priceInCents: v.number(),
|
|
235
|
+
})
|
|
236
|
+
),
|
|
237
|
+
eventId: v.optional(v.string()),
|
|
238
|
+
}),
|
|
239
|
+
|
|
240
|
+
// L4YERCAK3 sync
|
|
241
|
+
l4yercak3OrderId: v.optional(v.string()),
|
|
242
|
+
l4yercak3InvoiceId: v.optional(v.string()),
|
|
243
|
+
syncStatus: v.string(),
|
|
244
|
+
syncedAt: v.optional(v.number()),
|
|
245
|
+
|
|
246
|
+
// Timestamps
|
|
247
|
+
createdAt: v.number(),
|
|
248
|
+
completedAt: v.optional(v.number()),
|
|
249
|
+
})
|
|
250
|
+
.index("by_stripe_id", ["stripePaymentIntentId"])
|
|
251
|
+
.index("by_customer", ["stripeCustomerId"])
|
|
252
|
+
.index("by_user", ["frontendUserId"])
|
|
253
|
+
.index("by_sync_status", ["syncStatus"]),
|
|
254
|
+
|
|
255
|
+
// ============================================
|
|
256
|
+
// SYNC: Job tracking
|
|
257
|
+
// ============================================
|
|
258
|
+
|
|
259
|
+
syncJobs: defineTable({
|
|
260
|
+
entityType: v.string(),
|
|
261
|
+
direction: v.union(
|
|
262
|
+
v.literal("push"),
|
|
263
|
+
v.literal("pull"),
|
|
264
|
+
v.literal("bidirectional")
|
|
265
|
+
),
|
|
266
|
+
status: v.union(
|
|
267
|
+
v.literal("pending"),
|
|
268
|
+
v.literal("running"),
|
|
269
|
+
v.literal("completed"),
|
|
270
|
+
v.literal("failed")
|
|
271
|
+
),
|
|
272
|
+
|
|
273
|
+
cursor: v.optional(v.string()),
|
|
274
|
+
processedCount: v.number(),
|
|
275
|
+
totalCount: v.optional(v.number()),
|
|
276
|
+
|
|
277
|
+
errorMessage: v.optional(v.string()),
|
|
278
|
+
errorDetails: v.optional(v.any()),
|
|
279
|
+
|
|
280
|
+
startedAt: v.number(),
|
|
281
|
+
completedAt: v.optional(v.number()),
|
|
282
|
+
})
|
|
283
|
+
.index("by_status", ["status"])
|
|
284
|
+
.index("by_entity", ["entityType"]),
|
|
285
|
+
|
|
286
|
+
syncConflicts: defineTable({
|
|
287
|
+
objectId: v.id("objects"),
|
|
288
|
+
localVersion: v.any(),
|
|
289
|
+
remoteVersion: v.any(),
|
|
290
|
+
conflictType: v.string(), // "update_conflict", "delete_conflict"
|
|
291
|
+
resolvedAt: v.optional(v.number()),
|
|
292
|
+
resolution: v.optional(v.string()), // "local_wins", "remote_wins", "merged"
|
|
293
|
+
createdAt: v.number(),
|
|
294
|
+
})
|
|
295
|
+
.index("by_object", ["objectId"])
|
|
296
|
+
.index("by_unresolved", ["resolvedAt"]),
|
|
297
|
+
});
|
|
298
|
+
`;
|
|
299
|
+
|
|
300
|
+
return writeFileWithBackup(outputPath, content, action);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async generateObjects(convexDir) {
|
|
304
|
+
const outputPath = path.join(convexDir, 'objects.ts');
|
|
305
|
+
|
|
306
|
+
const action = await checkFileOverwrite(outputPath);
|
|
307
|
+
if (action === 'skip') {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const content = `/**
|
|
312
|
+
* Convex Objects Queries and Mutations
|
|
313
|
+
* Auto-generated by @l4yercak3/cli
|
|
314
|
+
*
|
|
315
|
+
* Universal object operations for the ontology-first pattern.
|
|
316
|
+
*/
|
|
317
|
+
|
|
318
|
+
import { v } from "convex/values";
|
|
319
|
+
import { query, mutation } from "./_generated/server";
|
|
320
|
+
|
|
321
|
+
// ============ Queries ============
|
|
322
|
+
|
|
323
|
+
export const list = query({
|
|
324
|
+
args: {
|
|
325
|
+
type: v.string(),
|
|
326
|
+
status: v.optional(v.string()),
|
|
327
|
+
subtype: v.optional(v.string()),
|
|
328
|
+
limit: v.optional(v.number()),
|
|
329
|
+
},
|
|
330
|
+
handler: async (ctx, args) => {
|
|
331
|
+
let q = ctx.db
|
|
332
|
+
.query("objects")
|
|
333
|
+
.withIndex("by_type", (q) => q.eq("type", args.type));
|
|
334
|
+
|
|
335
|
+
const objects = await q.collect();
|
|
336
|
+
|
|
337
|
+
// Filter by status/subtype if provided
|
|
338
|
+
let filtered = objects.filter((o) => !o.deletedAt);
|
|
339
|
+
if (args.status) {
|
|
340
|
+
filtered = filtered.filter((o) => o.status === args.status);
|
|
341
|
+
}
|
|
342
|
+
if (args.subtype) {
|
|
343
|
+
filtered = filtered.filter((o) => o.subtype === args.subtype);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Sort by updatedAt descending
|
|
347
|
+
filtered.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
348
|
+
|
|
349
|
+
// Apply limit
|
|
350
|
+
if (args.limit) {
|
|
351
|
+
filtered = filtered.slice(0, args.limit);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return filtered;
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
export const get = query({
|
|
359
|
+
args: { id: v.id("objects") },
|
|
360
|
+
handler: async (ctx, args) => {
|
|
361
|
+
return ctx.db.get(args.id);
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
export const getByL4yercak3Id = query({
|
|
366
|
+
args: { l4yercak3Id: v.string() },
|
|
367
|
+
handler: async (ctx, args) => {
|
|
368
|
+
return ctx.db
|
|
369
|
+
.query("objects")
|
|
370
|
+
.withIndex("by_l4yercak3_id", (q) => q.eq("l4yercak3Id", args.l4yercak3Id))
|
|
371
|
+
.first();
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
export const search = query({
|
|
376
|
+
args: {
|
|
377
|
+
type: v.string(),
|
|
378
|
+
searchTerm: v.string(),
|
|
379
|
+
},
|
|
380
|
+
handler: async (ctx, args) => {
|
|
381
|
+
const objects = await ctx.db
|
|
382
|
+
.query("objects")
|
|
383
|
+
.withIndex("by_type", (q) => q.eq("type", args.type))
|
|
384
|
+
.collect();
|
|
385
|
+
|
|
386
|
+
const term = args.searchTerm.toLowerCase();
|
|
387
|
+
return objects.filter(
|
|
388
|
+
(o) =>
|
|
389
|
+
!o.deletedAt &&
|
|
390
|
+
(o.name.toLowerCase().includes(term) ||
|
|
391
|
+
JSON.stringify(o.customProperties).toLowerCase().includes(term))
|
|
392
|
+
);
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ============ Mutations ============
|
|
397
|
+
|
|
398
|
+
export const create = mutation({
|
|
399
|
+
args: {
|
|
400
|
+
type: v.string(),
|
|
401
|
+
subtype: v.optional(v.string()),
|
|
402
|
+
name: v.string(),
|
|
403
|
+
status: v.string(),
|
|
404
|
+
customProperties: v.any(),
|
|
405
|
+
organizationId: v.string(),
|
|
406
|
+
l4yercak3Id: v.optional(v.string()),
|
|
407
|
+
},
|
|
408
|
+
handler: async (ctx, args) => {
|
|
409
|
+
const now = Date.now();
|
|
410
|
+
|
|
411
|
+
const id = await ctx.db.insert("objects", {
|
|
412
|
+
type: args.type,
|
|
413
|
+
subtype: args.subtype,
|
|
414
|
+
name: args.name,
|
|
415
|
+
status: args.status,
|
|
416
|
+
customProperties: args.customProperties,
|
|
417
|
+
organizationId: args.organizationId,
|
|
418
|
+
l4yercak3Id: args.l4yercak3Id,
|
|
419
|
+
syncStatus: args.l4yercak3Id ? "synced" : "local_only",
|
|
420
|
+
syncedAt: args.l4yercak3Id ? now : undefined,
|
|
421
|
+
localVersion: 1,
|
|
422
|
+
createdAt: now,
|
|
423
|
+
updatedAt: now,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return id;
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
export const update = mutation({
|
|
431
|
+
args: {
|
|
432
|
+
id: v.id("objects"),
|
|
433
|
+
name: v.optional(v.string()),
|
|
434
|
+
status: v.optional(v.string()),
|
|
435
|
+
subtype: v.optional(v.string()),
|
|
436
|
+
customProperties: v.optional(v.any()),
|
|
437
|
+
},
|
|
438
|
+
handler: async (ctx, args) => {
|
|
439
|
+
const existing = await ctx.db.get(args.id);
|
|
440
|
+
if (!existing) {
|
|
441
|
+
throw new Error("Object not found");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const updates: Record<string, unknown> = {
|
|
445
|
+
updatedAt: Date.now(),
|
|
446
|
+
localVersion: existing.localVersion + 1,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
if (args.name !== undefined) updates.name = args.name;
|
|
450
|
+
if (args.status !== undefined) updates.status = args.status;
|
|
451
|
+
if (args.subtype !== undefined) updates.subtype = args.subtype;
|
|
452
|
+
if (args.customProperties !== undefined) {
|
|
453
|
+
updates.customProperties = {
|
|
454
|
+
...existing.customProperties,
|
|
455
|
+
...args.customProperties,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Mark as pending push if it was previously synced
|
|
460
|
+
if (existing.syncStatus === "synced") {
|
|
461
|
+
updates.syncStatus = "pending_push";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
await ctx.db.patch(args.id, updates);
|
|
465
|
+
return args.id;
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
export const remove = mutation({
|
|
470
|
+
args: { id: v.id("objects") },
|
|
471
|
+
handler: async (ctx, args) => {
|
|
472
|
+
const existing = await ctx.db.get(args.id);
|
|
473
|
+
if (!existing) {
|
|
474
|
+
throw new Error("Object not found");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Soft delete
|
|
478
|
+
await ctx.db.patch(args.id, {
|
|
479
|
+
deletedAt: Date.now(),
|
|
480
|
+
updatedAt: Date.now(),
|
|
481
|
+
syncStatus: existing.l4yercak3Id ? "pending_push" : "local_only",
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
return args.id;
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ============ Links ============
|
|
489
|
+
|
|
490
|
+
export const createLink = mutation({
|
|
491
|
+
args: {
|
|
492
|
+
fromObjectId: v.id("objects"),
|
|
493
|
+
toObjectId: v.id("objects"),
|
|
494
|
+
linkType: v.string(),
|
|
495
|
+
metadata: v.optional(v.any()),
|
|
496
|
+
},
|
|
497
|
+
handler: async (ctx, args) => {
|
|
498
|
+
const id = await ctx.db.insert("objectLinks", {
|
|
499
|
+
fromObjectId: args.fromObjectId,
|
|
500
|
+
toObjectId: args.toObjectId,
|
|
501
|
+
linkType: args.linkType,
|
|
502
|
+
metadata: args.metadata,
|
|
503
|
+
syncStatus: "local_only",
|
|
504
|
+
createdAt: Date.now(),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
return id;
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
export const getLinks = query({
|
|
512
|
+
args: {
|
|
513
|
+
objectId: v.id("objects"),
|
|
514
|
+
linkType: v.optional(v.string()),
|
|
515
|
+
direction: v.optional(v.union(v.literal("from"), v.literal("to"))),
|
|
516
|
+
},
|
|
517
|
+
handler: async (ctx, args) => {
|
|
518
|
+
const direction = args.direction || "from";
|
|
519
|
+
|
|
520
|
+
let links;
|
|
521
|
+
if (direction === "from") {
|
|
522
|
+
links = await ctx.db
|
|
523
|
+
.query("objectLinks")
|
|
524
|
+
.withIndex("by_from", (q) => q.eq("fromObjectId", args.objectId))
|
|
525
|
+
.collect();
|
|
526
|
+
} else {
|
|
527
|
+
links = await ctx.db
|
|
528
|
+
.query("objectLinks")
|
|
529
|
+
.withIndex("by_to", (q) => q.eq("toObjectId", args.objectId))
|
|
530
|
+
.collect();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (args.linkType) {
|
|
534
|
+
links = links.filter((l) => l.linkType === args.linkType);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return links;
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ============ Sync Helpers ============
|
|
542
|
+
|
|
543
|
+
export const getPendingSync = query({
|
|
544
|
+
args: { direction: v.union(v.literal("push"), v.literal("pull")) },
|
|
545
|
+
handler: async (ctx, args) => {
|
|
546
|
+
const status = args.direction === "push" ? "pending_push" : "pending_pull";
|
|
547
|
+
|
|
548
|
+
return ctx.db
|
|
549
|
+
.query("objects")
|
|
550
|
+
.withIndex("by_sync_status", (q) => q.eq("syncStatus", status))
|
|
551
|
+
.collect();
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
export const markSynced = mutation({
|
|
556
|
+
args: {
|
|
557
|
+
id: v.id("objects"),
|
|
558
|
+
l4yercak3Id: v.string(),
|
|
559
|
+
remoteVersion: v.optional(v.number()),
|
|
560
|
+
},
|
|
561
|
+
handler: async (ctx, args) => {
|
|
562
|
+
await ctx.db.patch(args.id, {
|
|
563
|
+
l4yercak3Id: args.l4yercak3Id,
|
|
564
|
+
syncStatus: "synced",
|
|
565
|
+
syncedAt: Date.now(),
|
|
566
|
+
remoteVersion: args.remoteVersion,
|
|
567
|
+
});
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
`;
|
|
571
|
+
|
|
572
|
+
return writeFileWithBackup(outputPath, content, action);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async generateFrontendUsers(convexDir) {
|
|
576
|
+
const outputPath = path.join(convexDir, 'frontendUsers.ts');
|
|
577
|
+
|
|
578
|
+
const action = await checkFileOverwrite(outputPath);
|
|
579
|
+
if (action === 'skip') {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const content = `/**
|
|
584
|
+
* Convex Frontend Users Queries and Mutations
|
|
585
|
+
* Auto-generated by @l4yercak3/cli
|
|
586
|
+
*
|
|
587
|
+
* Handles local user authentication and syncs users as CRM contacts.
|
|
588
|
+
*/
|
|
589
|
+
|
|
590
|
+
import { v } from "convex/values";
|
|
591
|
+
import { query, mutation } from "./_generated/server";
|
|
592
|
+
|
|
593
|
+
// ============ Queries ============
|
|
594
|
+
|
|
595
|
+
export const getByEmail = query({
|
|
596
|
+
args: { email: v.string() },
|
|
597
|
+
handler: async (ctx, args) => {
|
|
598
|
+
return ctx.db
|
|
599
|
+
.query("frontendUsers")
|
|
600
|
+
.withIndex("by_email", (q) => q.eq("email", args.email))
|
|
601
|
+
.first();
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
export const get = query({
|
|
606
|
+
args: { id: v.id("frontendUsers") },
|
|
607
|
+
handler: async (ctx, args) => {
|
|
608
|
+
return ctx.db.get(args.id);
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
export const getBySession = query({
|
|
613
|
+
args: { sessionToken: v.string() },
|
|
614
|
+
handler: async (ctx, args) => {
|
|
615
|
+
const session = await ctx.db
|
|
616
|
+
.query("sessions")
|
|
617
|
+
.withIndex("by_token", (q) => q.eq("sessionToken", args.sessionToken))
|
|
618
|
+
.first();
|
|
619
|
+
|
|
620
|
+
if (!session || session.expiresAt < Date.now()) {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return ctx.db.get(session.userId);
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// ============ Mutations ============
|
|
629
|
+
|
|
630
|
+
export const create = mutation({
|
|
631
|
+
args: {
|
|
632
|
+
email: v.string(),
|
|
633
|
+
name: v.optional(v.string()),
|
|
634
|
+
firstName: v.optional(v.string()),
|
|
635
|
+
lastName: v.optional(v.string()),
|
|
636
|
+
image: v.optional(v.string()),
|
|
637
|
+
organizationId: v.string(),
|
|
638
|
+
role: v.optional(v.string()),
|
|
639
|
+
},
|
|
640
|
+
handler: async (ctx, args) => {
|
|
641
|
+
// Check if user already exists
|
|
642
|
+
const existing = await ctx.db
|
|
643
|
+
.query("frontendUsers")
|
|
644
|
+
.withIndex("by_email", (q) => q.eq("email", args.email))
|
|
645
|
+
.first();
|
|
646
|
+
|
|
647
|
+
if (existing) {
|
|
648
|
+
throw new Error("User with this email already exists");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
|
|
653
|
+
const id = await ctx.db.insert("frontendUsers", {
|
|
654
|
+
email: args.email,
|
|
655
|
+
emailVerified: false,
|
|
656
|
+
name: args.name,
|
|
657
|
+
firstName: args.firstName,
|
|
658
|
+
lastName: args.lastName,
|
|
659
|
+
image: args.image,
|
|
660
|
+
organizationId: args.organizationId,
|
|
661
|
+
role: args.role || "user",
|
|
662
|
+
oauthAccounts: [],
|
|
663
|
+
syncStatus: "pending_push", // Will sync to L4YERCAK3 as CRM contact
|
|
664
|
+
createdAt: now,
|
|
665
|
+
updatedAt: now,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
return id;
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
export const update = mutation({
|
|
673
|
+
args: {
|
|
674
|
+
id: v.id("frontendUsers"),
|
|
675
|
+
name: v.optional(v.string()),
|
|
676
|
+
firstName: v.optional(v.string()),
|
|
677
|
+
lastName: v.optional(v.string()),
|
|
678
|
+
image: v.optional(v.string()),
|
|
679
|
+
phone: v.optional(v.string()),
|
|
680
|
+
preferences: v.optional(v.any()),
|
|
681
|
+
},
|
|
682
|
+
handler: async (ctx, args) => {
|
|
683
|
+
const { id, ...updates } = args;
|
|
684
|
+
|
|
685
|
+
const existing = await ctx.db.get(id);
|
|
686
|
+
if (!existing) {
|
|
687
|
+
throw new Error("User not found");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
await ctx.db.patch(id, {
|
|
691
|
+
...updates,
|
|
692
|
+
updatedAt: Date.now(),
|
|
693
|
+
syncStatus: existing.l4yercak3ContactId ? "pending_push" : "pending_push",
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
return id;
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
export const linkOAuthAccount = mutation({
|
|
701
|
+
args: {
|
|
702
|
+
userId: v.id("frontendUsers"),
|
|
703
|
+
provider: v.string(),
|
|
704
|
+
providerAccountId: v.string(),
|
|
705
|
+
accessToken: v.optional(v.string()),
|
|
706
|
+
refreshToken: v.optional(v.string()),
|
|
707
|
+
expiresAt: v.optional(v.number()),
|
|
708
|
+
scope: v.optional(v.string()),
|
|
709
|
+
},
|
|
710
|
+
handler: async (ctx, args) => {
|
|
711
|
+
const user = await ctx.db.get(args.userId);
|
|
712
|
+
if (!user) {
|
|
713
|
+
throw new Error("User not found");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Check if this OAuth account is already linked
|
|
717
|
+
const existingAccount = user.oauthAccounts.find(
|
|
718
|
+
(a) =>
|
|
719
|
+
a.provider === args.provider &&
|
|
720
|
+
a.providerAccountId === args.providerAccountId
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
let oauthAccounts;
|
|
724
|
+
if (existingAccount) {
|
|
725
|
+
// Update existing account
|
|
726
|
+
oauthAccounts = user.oauthAccounts.map((a) =>
|
|
727
|
+
a.provider === args.provider &&
|
|
728
|
+
a.providerAccountId === args.providerAccountId
|
|
729
|
+
? {
|
|
730
|
+
provider: args.provider,
|
|
731
|
+
providerAccountId: args.providerAccountId,
|
|
732
|
+
accessToken: args.accessToken,
|
|
733
|
+
refreshToken: args.refreshToken,
|
|
734
|
+
expiresAt: args.expiresAt,
|
|
735
|
+
scope: args.scope,
|
|
736
|
+
}
|
|
737
|
+
: a
|
|
738
|
+
);
|
|
739
|
+
} else {
|
|
740
|
+
// Add new account
|
|
741
|
+
oauthAccounts = [
|
|
742
|
+
...user.oauthAccounts,
|
|
743
|
+
{
|
|
744
|
+
provider: args.provider,
|
|
745
|
+
providerAccountId: args.providerAccountId,
|
|
746
|
+
accessToken: args.accessToken,
|
|
747
|
+
refreshToken: args.refreshToken,
|
|
748
|
+
expiresAt: args.expiresAt,
|
|
749
|
+
scope: args.scope,
|
|
750
|
+
},
|
|
751
|
+
];
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
await ctx.db.patch(args.userId, {
|
|
755
|
+
oauthAccounts,
|
|
756
|
+
emailVerified: true, // OAuth email is verified
|
|
757
|
+
updatedAt: Date.now(),
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
return args.userId;
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
export const createSession = mutation({
|
|
765
|
+
args: {
|
|
766
|
+
userId: v.id("frontendUsers"),
|
|
767
|
+
sessionToken: v.string(),
|
|
768
|
+
expiresAt: v.number(),
|
|
769
|
+
userAgent: v.optional(v.string()),
|
|
770
|
+
ipAddress: v.optional(v.string()),
|
|
771
|
+
},
|
|
772
|
+
handler: async (ctx, args) => {
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
|
|
775
|
+
// Update last login
|
|
776
|
+
await ctx.db.patch(args.userId, {
|
|
777
|
+
lastLoginAt: now,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const id = await ctx.db.insert("sessions", {
|
|
781
|
+
userId: args.userId,
|
|
782
|
+
sessionToken: args.sessionToken,
|
|
783
|
+
expiresAt: args.expiresAt,
|
|
784
|
+
userAgent: args.userAgent,
|
|
785
|
+
ipAddress: args.ipAddress,
|
|
786
|
+
createdAt: now,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
return id;
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
export const deleteSession = mutation({
|
|
794
|
+
args: { sessionToken: v.string() },
|
|
795
|
+
handler: async (ctx, args) => {
|
|
796
|
+
const session = await ctx.db
|
|
797
|
+
.query("sessions")
|
|
798
|
+
.withIndex("by_token", (q) => q.eq("sessionToken", args.sessionToken))
|
|
799
|
+
.first();
|
|
800
|
+
|
|
801
|
+
if (session) {
|
|
802
|
+
await ctx.db.delete(session._id);
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// ============ Sync Helpers ============
|
|
808
|
+
|
|
809
|
+
export const markSyncedAsContact = mutation({
|
|
810
|
+
args: {
|
|
811
|
+
id: v.id("frontendUsers"),
|
|
812
|
+
l4yercak3ContactId: v.string(),
|
|
813
|
+
},
|
|
814
|
+
handler: async (ctx, args) => {
|
|
815
|
+
await ctx.db.patch(args.id, {
|
|
816
|
+
l4yercak3ContactId: args.l4yercak3ContactId,
|
|
817
|
+
syncStatus: "synced",
|
|
818
|
+
syncedAt: Date.now(),
|
|
819
|
+
});
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
export const getPendingSync = query({
|
|
824
|
+
args: {},
|
|
825
|
+
handler: async (ctx) => {
|
|
826
|
+
const users = await ctx.db.query("frontendUsers").collect();
|
|
827
|
+
return users.filter((u) => u.syncStatus === "pending_push");
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
`;
|
|
831
|
+
|
|
832
|
+
return writeFileWithBackup(outputPath, content, action);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async generateSync(convexDir) {
|
|
836
|
+
const outputPath = path.join(convexDir, 'sync.ts');
|
|
837
|
+
|
|
838
|
+
const action = await checkFileOverwrite(outputPath);
|
|
839
|
+
if (action === 'skip') {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const content = `/**
|
|
844
|
+
* Convex L4YERCAK3 Sync Logic
|
|
845
|
+
* Auto-generated by @l4yercak3/cli
|
|
846
|
+
*
|
|
847
|
+
* Handles bidirectional sync between local Convex and L4YERCAK3 backend.
|
|
848
|
+
*/
|
|
849
|
+
|
|
850
|
+
import { v } from "convex/values";
|
|
851
|
+
import { internalMutation, internalQuery, action } from "./_generated/server";
|
|
852
|
+
import { internal } from "./_generated/api";
|
|
853
|
+
|
|
854
|
+
// ============ Sync Job Management ============
|
|
855
|
+
|
|
856
|
+
export const createSyncJob = internalMutation({
|
|
857
|
+
args: {
|
|
858
|
+
entityType: v.string(),
|
|
859
|
+
direction: v.union(
|
|
860
|
+
v.literal("push"),
|
|
861
|
+
v.literal("pull"),
|
|
862
|
+
v.literal("bidirectional")
|
|
863
|
+
),
|
|
864
|
+
},
|
|
865
|
+
handler: async (ctx, args) => {
|
|
866
|
+
const id = await ctx.db.insert("syncJobs", {
|
|
867
|
+
entityType: args.entityType,
|
|
868
|
+
direction: args.direction,
|
|
869
|
+
status: "pending",
|
|
870
|
+
processedCount: 0,
|
|
871
|
+
startedAt: Date.now(),
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
return id;
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
export const updateSyncJob = internalMutation({
|
|
879
|
+
args: {
|
|
880
|
+
id: v.id("syncJobs"),
|
|
881
|
+
status: v.optional(
|
|
882
|
+
v.union(
|
|
883
|
+
v.literal("pending"),
|
|
884
|
+
v.literal("running"),
|
|
885
|
+
v.literal("completed"),
|
|
886
|
+
v.literal("failed")
|
|
887
|
+
)
|
|
888
|
+
),
|
|
889
|
+
processedCount: v.optional(v.number()),
|
|
890
|
+
totalCount: v.optional(v.number()),
|
|
891
|
+
cursor: v.optional(v.string()),
|
|
892
|
+
errorMessage: v.optional(v.string()),
|
|
893
|
+
errorDetails: v.optional(v.any()),
|
|
894
|
+
},
|
|
895
|
+
handler: async (ctx, args) => {
|
|
896
|
+
const { id, ...updates } = args;
|
|
897
|
+
|
|
898
|
+
const finalUpdates: Record<string, unknown> = { ...updates };
|
|
899
|
+
|
|
900
|
+
if (updates.status === "completed" || updates.status === "failed") {
|
|
901
|
+
finalUpdates.completedAt = Date.now();
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
await ctx.db.patch(id, finalUpdates);
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// ============ Conflict Resolution ============
|
|
909
|
+
|
|
910
|
+
export const recordConflict = internalMutation({
|
|
911
|
+
args: {
|
|
912
|
+
objectId: v.id("objects"),
|
|
913
|
+
localVersion: v.any(),
|
|
914
|
+
remoteVersion: v.any(),
|
|
915
|
+
conflictType: v.string(),
|
|
916
|
+
},
|
|
917
|
+
handler: async (ctx, args) => {
|
|
918
|
+
const id = await ctx.db.insert("syncConflicts", {
|
|
919
|
+
objectId: args.objectId,
|
|
920
|
+
localVersion: args.localVersion,
|
|
921
|
+
remoteVersion: args.remoteVersion,
|
|
922
|
+
conflictType: args.conflictType,
|
|
923
|
+
createdAt: Date.now(),
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// Mark object as conflicted
|
|
927
|
+
await ctx.db.patch(args.objectId, {
|
|
928
|
+
syncStatus: "conflict",
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
return id;
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
export const resolveConflict = internalMutation({
|
|
936
|
+
args: {
|
|
937
|
+
conflictId: v.id("syncConflicts"),
|
|
938
|
+
resolution: v.union(
|
|
939
|
+
v.literal("local_wins"),
|
|
940
|
+
v.literal("remote_wins"),
|
|
941
|
+
v.literal("merged")
|
|
942
|
+
),
|
|
943
|
+
mergedData: v.optional(v.any()),
|
|
944
|
+
},
|
|
945
|
+
handler: async (ctx, args) => {
|
|
946
|
+
const conflict = await ctx.db.get(args.conflictId);
|
|
947
|
+
if (!conflict) {
|
|
948
|
+
throw new Error("Conflict not found");
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Apply resolution
|
|
952
|
+
if (args.resolution === "local_wins") {
|
|
953
|
+
await ctx.db.patch(conflict.objectId, {
|
|
954
|
+
syncStatus: "pending_push",
|
|
955
|
+
});
|
|
956
|
+
} else if (args.resolution === "remote_wins") {
|
|
957
|
+
await ctx.db.patch(conflict.objectId, {
|
|
958
|
+
customProperties: conflict.remoteVersion,
|
|
959
|
+
syncStatus: "synced",
|
|
960
|
+
syncedAt: Date.now(),
|
|
961
|
+
});
|
|
962
|
+
} else if (args.resolution === "merged" && args.mergedData) {
|
|
963
|
+
await ctx.db.patch(conflict.objectId, {
|
|
964
|
+
customProperties: args.mergedData,
|
|
965
|
+
syncStatus: "pending_push",
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Mark conflict as resolved
|
|
970
|
+
await ctx.db.patch(args.conflictId, {
|
|
971
|
+
resolvedAt: Date.now(),
|
|
972
|
+
resolution: args.resolution,
|
|
973
|
+
});
|
|
974
|
+
},
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// ============ Sync Actions ============
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Push local changes to L4YERCAK3
|
|
981
|
+
* Call this action to sync pending local changes to the backend.
|
|
982
|
+
*/
|
|
983
|
+
export const pushToL4yercak3 = action({
|
|
984
|
+
args: {
|
|
985
|
+
entityType: v.optional(v.string()),
|
|
986
|
+
},
|
|
987
|
+
handler: async (ctx, args) => {
|
|
988
|
+
// This is a placeholder - actual implementation requires:
|
|
989
|
+
// 1. L4YERCAK3 API key from environment
|
|
990
|
+
// 2. Fetching pending items from Convex
|
|
991
|
+
// 3. Making API calls to L4YERCAK3
|
|
992
|
+
// 4. Updating sync status
|
|
993
|
+
|
|
994
|
+
const apiKey = process.env.L4YERCAK3_API_KEY;
|
|
995
|
+
if (!apiKey) {
|
|
996
|
+
throw new Error("L4YERCAK3_API_KEY environment variable required");
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Create sync job
|
|
1000
|
+
const jobId = await ctx.runMutation(internal.sync.createSyncJob, {
|
|
1001
|
+
entityType: args.entityType || "all",
|
|
1002
|
+
direction: "push",
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
await ctx.runMutation(internal.sync.updateSyncJob, {
|
|
1006
|
+
id: jobId,
|
|
1007
|
+
status: "running",
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
try {
|
|
1011
|
+
// TODO: Implement actual sync logic
|
|
1012
|
+
// 1. Query pending_push objects
|
|
1013
|
+
// 2. For each object, call L4YERCAK3 API
|
|
1014
|
+
// 3. Update local records with l4yercak3Id
|
|
1015
|
+
// 4. Mark as synced
|
|
1016
|
+
|
|
1017
|
+
await ctx.runMutation(internal.sync.updateSyncJob, {
|
|
1018
|
+
id: jobId,
|
|
1019
|
+
status: "completed",
|
|
1020
|
+
processedCount: 0,
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
return { success: true, jobId };
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
await ctx.runMutation(internal.sync.updateSyncJob, {
|
|
1026
|
+
id: jobId,
|
|
1027
|
+
status: "failed",
|
|
1028
|
+
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
throw error;
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Pull changes from L4YERCAK3
|
|
1038
|
+
* Call this action to fetch updates from the backend.
|
|
1039
|
+
*/
|
|
1040
|
+
export const pullFromL4yercak3 = action({
|
|
1041
|
+
args: {
|
|
1042
|
+
entityType: v.optional(v.string()),
|
|
1043
|
+
since: v.optional(v.number()),
|
|
1044
|
+
},
|
|
1045
|
+
handler: async (ctx, args) => {
|
|
1046
|
+
const apiKey = process.env.L4YERCAK3_API_KEY;
|
|
1047
|
+
if (!apiKey) {
|
|
1048
|
+
throw new Error("L4YERCAK3_API_KEY environment variable required");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Create sync job
|
|
1052
|
+
const jobId = await ctx.runMutation(internal.sync.createSyncJob, {
|
|
1053
|
+
entityType: args.entityType || "all",
|
|
1054
|
+
direction: "pull",
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
await ctx.runMutation(internal.sync.updateSyncJob, {
|
|
1058
|
+
id: jobId,
|
|
1059
|
+
status: "running",
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
try {
|
|
1063
|
+
// TODO: Implement actual sync logic
|
|
1064
|
+
// 1. Call L4YERCAK3 API to get updated records
|
|
1065
|
+
// 2. For each record, check if local version exists
|
|
1066
|
+
// 3. If exists and different, create conflict or update
|
|
1067
|
+
// 4. If doesn't exist, create new record
|
|
1068
|
+
|
|
1069
|
+
await ctx.runMutation(internal.sync.updateSyncJob, {
|
|
1070
|
+
id: jobId,
|
|
1071
|
+
status: "completed",
|
|
1072
|
+
processedCount: 0,
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
return { success: true, jobId };
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
await ctx.runMutation(internal.sync.updateSyncJob, {
|
|
1078
|
+
id: jobId,
|
|
1079
|
+
status: "failed",
|
|
1080
|
+
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
throw error;
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// ============ Queries ============
|
|
1089
|
+
|
|
1090
|
+
export const getUnresolvedConflicts = internalQuery({
|
|
1091
|
+
args: {},
|
|
1092
|
+
handler: async (ctx) => {
|
|
1093
|
+
return ctx.db
|
|
1094
|
+
.query("syncConflicts")
|
|
1095
|
+
.withIndex("by_unresolved", (q) => q.eq("resolvedAt", undefined))
|
|
1096
|
+
.collect();
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
export const getRecentSyncJobs = internalQuery({
|
|
1101
|
+
args: { limit: v.optional(v.number()) },
|
|
1102
|
+
handler: async (ctx, args) => {
|
|
1103
|
+
const jobs = await ctx.db.query("syncJobs").order("desc").collect();
|
|
1104
|
+
return jobs.slice(0, args.limit || 10);
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
`;
|
|
1108
|
+
|
|
1109
|
+
return writeFileWithBackup(outputPath, content, action);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async generateHttp(convexDir) {
|
|
1113
|
+
const outputPath = path.join(convexDir, 'http.ts');
|
|
1114
|
+
|
|
1115
|
+
const action = await checkFileOverwrite(outputPath);
|
|
1116
|
+
if (action === 'skip') {
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const content = `/**
|
|
1121
|
+
* Convex HTTP Endpoints
|
|
1122
|
+
* Auto-generated by @l4yercak3/cli
|
|
1123
|
+
*
|
|
1124
|
+
* Webhook endpoints for L4YERCAK3 and Stripe.
|
|
1125
|
+
*/
|
|
1126
|
+
|
|
1127
|
+
import { httpRouter } from "convex/server";
|
|
1128
|
+
import { httpAction } from "./_generated/server";
|
|
1129
|
+
|
|
1130
|
+
const http = httpRouter();
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* L4YERCAK3 Webhook Handler
|
|
1134
|
+
* Receives events from L4YERCAK3 backend
|
|
1135
|
+
*/
|
|
1136
|
+
http.route({
|
|
1137
|
+
path: "/webhooks/l4yercak3",
|
|
1138
|
+
method: "POST",
|
|
1139
|
+
handler: httpAction(async (ctx, request) => {
|
|
1140
|
+
const signature = request.headers.get("x-l4yercak3-signature");
|
|
1141
|
+
const webhookSecret = process.env.L4YERCAK3_WEBHOOK_SECRET;
|
|
1142
|
+
|
|
1143
|
+
if (!webhookSecret) {
|
|
1144
|
+
return new Response("Webhook secret not configured", { status: 500 });
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Get raw body for signature verification
|
|
1148
|
+
const body = await request.text();
|
|
1149
|
+
|
|
1150
|
+
// TODO: Verify signature using the same logic as webhooks.ts
|
|
1151
|
+
// For now, basic validation
|
|
1152
|
+
if (!signature) {
|
|
1153
|
+
return new Response("Missing signature", { status: 401 });
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
try {
|
|
1157
|
+
const event = JSON.parse(body);
|
|
1158
|
+
|
|
1159
|
+
// Handle different event types
|
|
1160
|
+
switch (event.type) {
|
|
1161
|
+
case "contact.created":
|
|
1162
|
+
case "contact.updated":
|
|
1163
|
+
// Sync contact to local objects
|
|
1164
|
+
console.log("Contact event:", event.type, event.data);
|
|
1165
|
+
break;
|
|
1166
|
+
|
|
1167
|
+
case "event.created":
|
|
1168
|
+
case "event.updated":
|
|
1169
|
+
// Sync event to local objects
|
|
1170
|
+
console.log("Event event:", event.type, event.data);
|
|
1171
|
+
break;
|
|
1172
|
+
|
|
1173
|
+
case "order.created":
|
|
1174
|
+
case "order.paid":
|
|
1175
|
+
// Sync order to local objects
|
|
1176
|
+
console.log("Order event:", event.type, event.data);
|
|
1177
|
+
break;
|
|
1178
|
+
|
|
1179
|
+
default:
|
|
1180
|
+
console.log("Unhandled event type:", event.type);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
return new Response("OK", { status: 200 });
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
console.error("Webhook error:", error);
|
|
1186
|
+
return new Response("Invalid payload", { status: 400 });
|
|
1187
|
+
}
|
|
1188
|
+
}),
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Stripe Webhook Handler
|
|
1193
|
+
* Receives events from Stripe
|
|
1194
|
+
*/
|
|
1195
|
+
http.route({
|
|
1196
|
+
path: "/webhooks/stripe",
|
|
1197
|
+
method: "POST",
|
|
1198
|
+
handler: httpAction(async (ctx, request) => {
|
|
1199
|
+
const signature = request.headers.get("stripe-signature");
|
|
1200
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
1201
|
+
|
|
1202
|
+
if (!webhookSecret) {
|
|
1203
|
+
return new Response("Stripe webhook secret not configured", {
|
|
1204
|
+
status: 500,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (!signature) {
|
|
1209
|
+
return new Response("Missing Stripe signature", { status: 401 });
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const body = await request.text();
|
|
1213
|
+
|
|
1214
|
+
try {
|
|
1215
|
+
// TODO: Verify Stripe signature
|
|
1216
|
+
// const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
|
1217
|
+
// const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
|
1218
|
+
|
|
1219
|
+
const event = JSON.parse(body);
|
|
1220
|
+
|
|
1221
|
+
// Handle Stripe events
|
|
1222
|
+
switch (event.type) {
|
|
1223
|
+
case "payment_intent.succeeded":
|
|
1224
|
+
console.log("Payment succeeded:", event.data.object.id);
|
|
1225
|
+
// Store payment locally and sync to L4YERCAK3
|
|
1226
|
+
break;
|
|
1227
|
+
|
|
1228
|
+
case "payment_intent.payment_failed":
|
|
1229
|
+
console.log("Payment failed:", event.data.object.id);
|
|
1230
|
+
break;
|
|
1231
|
+
|
|
1232
|
+
case "customer.subscription.created":
|
|
1233
|
+
case "customer.subscription.updated":
|
|
1234
|
+
case "customer.subscription.deleted":
|
|
1235
|
+
console.log("Subscription event:", event.type);
|
|
1236
|
+
break;
|
|
1237
|
+
|
|
1238
|
+
default:
|
|
1239
|
+
console.log("Unhandled Stripe event:", event.type);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return new Response("OK", { status: 200 });
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
console.error("Stripe webhook error:", error);
|
|
1245
|
+
return new Response("Invalid payload", { status: 400 });
|
|
1246
|
+
}
|
|
1247
|
+
}),
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
export default http;
|
|
1251
|
+
`;
|
|
1252
|
+
|
|
1253
|
+
return writeFileWithBackup(outputPath, content, action);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
module.exports = new ConvexGenerator();
|