@fatagnus/convex-feedback 0.2.6 → 0.2.8

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.
@@ -0,0 +1,223 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query, internalQuery } from "./_generated/server";
3
+
4
+ /**
5
+ * Generate a secure random API key
6
+ * Format: fb_<32 random hex chars> = 35 chars total
7
+ */
8
+ function generateApiKey(): string {
9
+ const randomBytes = new Uint8Array(16);
10
+ crypto.getRandomValues(randomBytes);
11
+ const hex = Array.from(randomBytes)
12
+ .map((b) => b.toString(16).padStart(2, "0"))
13
+ .join("");
14
+ return `fb_${hex}`;
15
+ }
16
+
17
+ /**
18
+ * Hash an API key using SHA-256
19
+ */
20
+ async function hashApiKey(key: string): Promise<string> {
21
+ const encoder = new TextEncoder();
22
+ const data = encoder.encode(key);
23
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
24
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
25
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
26
+ }
27
+
28
+ /**
29
+ * Create a new API key.
30
+ * Returns the full key - this is the only time it will be visible!
31
+ */
32
+ export const create = mutation({
33
+ args: {
34
+ name: v.string(),
35
+ expiresAt: v.optional(v.number()),
36
+ },
37
+ returns: v.object({
38
+ key: v.string(),
39
+ keyPrefix: v.string(),
40
+ name: v.string(),
41
+ expiresAt: v.union(v.number(), v.null()),
42
+ createdAt: v.number(),
43
+ }),
44
+ handler: async (ctx, args) => {
45
+ const key = generateApiKey();
46
+ const keyHash = await hashApiKey(key);
47
+ const keyPrefix = key.slice(0, 11); // "fb_" + first 8 chars
48
+ const now = Date.now();
49
+
50
+ await ctx.db.insert("apiKeys", {
51
+ keyHash,
52
+ keyPrefix,
53
+ name: args.name,
54
+ expiresAt: args.expiresAt,
55
+ isRevoked: false,
56
+ createdAt: now,
57
+ });
58
+
59
+ return {
60
+ key, // Full key - only returned on creation!
61
+ keyPrefix,
62
+ name: args.name,
63
+ expiresAt: args.expiresAt ?? null,
64
+ createdAt: now,
65
+ };
66
+ },
67
+ });
68
+
69
+ /**
70
+ * List all API keys (without the actual key values).
71
+ */
72
+ export const list = query({
73
+ args: {},
74
+ returns: v.array(
75
+ v.object({
76
+ _id: v.id("apiKeys"),
77
+ keyPrefix: v.string(),
78
+ name: v.string(),
79
+ expiresAt: v.union(v.number(), v.null()),
80
+ isRevoked: v.boolean(),
81
+ revokedAt: v.union(v.number(), v.null()),
82
+ createdAt: v.number(),
83
+ isExpired: v.boolean(),
84
+ })
85
+ ),
86
+ handler: async (ctx) => {
87
+ const keys = await ctx.db.query("apiKeys").collect();
88
+ const now = Date.now();
89
+
90
+ return keys.map((k) => ({
91
+ _id: k._id,
92
+ keyPrefix: k.keyPrefix,
93
+ name: k.name,
94
+ expiresAt: k.expiresAt ?? null,
95
+ isRevoked: k.isRevoked,
96
+ revokedAt: k.revokedAt ?? null,
97
+ createdAt: k.createdAt,
98
+ isExpired: k.expiresAt !== undefined && k.expiresAt < now,
99
+ }));
100
+ },
101
+ });
102
+
103
+ /**
104
+ * Revoke an API key by its prefix.
105
+ */
106
+ export const revoke = mutation({
107
+ args: {
108
+ keyPrefix: v.string(),
109
+ },
110
+ returns: v.boolean(),
111
+ handler: async (ctx, args) => {
112
+ const key = await ctx.db
113
+ .query("apiKeys")
114
+ .withIndex("by_key_prefix", (q) => q.eq("keyPrefix", args.keyPrefix))
115
+ .unique();
116
+
117
+ if (!key) {
118
+ return false;
119
+ }
120
+
121
+ await ctx.db.patch(key._id, {
122
+ isRevoked: true,
123
+ revokedAt: Date.now(),
124
+ });
125
+
126
+ return true;
127
+ },
128
+ });
129
+
130
+ /**
131
+ * Rotate an API key - revokes the old key and creates a new one.
132
+ * Returns the new key (only time it will be visible!).
133
+ */
134
+ export const rotate = mutation({
135
+ args: {
136
+ keyPrefix: v.string(),
137
+ name: v.optional(v.string()),
138
+ expiresAt: v.optional(v.number()),
139
+ },
140
+ returns: v.union(
141
+ v.object({
142
+ key: v.string(),
143
+ keyPrefix: v.string(),
144
+ name: v.string(),
145
+ expiresAt: v.union(v.number(), v.null()),
146
+ createdAt: v.number(),
147
+ }),
148
+ v.null()
149
+ ),
150
+ handler: async (ctx, args) => {
151
+ // Find and revoke the old key
152
+ const oldKey = await ctx.db
153
+ .query("apiKeys")
154
+ .withIndex("by_key_prefix", (q) => q.eq("keyPrefix", args.keyPrefix))
155
+ .unique();
156
+
157
+ if (!oldKey) {
158
+ return null;
159
+ }
160
+
161
+ // Revoke old key
162
+ await ctx.db.patch(oldKey._id, {
163
+ isRevoked: true,
164
+ revokedAt: Date.now(),
165
+ });
166
+
167
+ // Create new key
168
+ const newKey = generateApiKey();
169
+ const keyHash = await hashApiKey(newKey);
170
+ const keyPrefix = newKey.slice(0, 11);
171
+ const now = Date.now();
172
+ const name = args.name ?? oldKey.name;
173
+
174
+ await ctx.db.insert("apiKeys", {
175
+ keyHash,
176
+ keyPrefix,
177
+ name,
178
+ expiresAt: args.expiresAt,
179
+ isRevoked: false,
180
+ createdAt: now,
181
+ });
182
+
183
+ return {
184
+ key: newKey,
185
+ keyPrefix,
186
+ name,
187
+ expiresAt: args.expiresAt ?? null,
188
+ createdAt: now,
189
+ };
190
+ },
191
+ });
192
+
193
+ /**
194
+ * Validate an API key (internal use for HTTP endpoint).
195
+ * Returns true if valid and not expired/revoked.
196
+ */
197
+ export const validateKey = internalQuery({
198
+ args: {
199
+ key: v.string(),
200
+ },
201
+ returns: v.boolean(),
202
+ handler: async (ctx, args) => {
203
+ const keyHash = await hashApiKey(args.key);
204
+ const apiKey = await ctx.db
205
+ .query("apiKeys")
206
+ .withIndex("by_key_hash", (q) => q.eq("keyHash", keyHash))
207
+ .unique();
208
+
209
+ if (!apiKey) {
210
+ return false;
211
+ }
212
+
213
+ if (apiKey.isRevoked) {
214
+ return false;
215
+ }
216
+
217
+ if (apiKey.expiresAt !== undefined && apiKey.expiresAt < Date.now()) {
218
+ return false;
219
+ }
220
+
221
+ return true;
222
+ },
223
+ });
@@ -1,5 +1,5 @@
1
1
  import { v } from "convex/values";
2
- import { mutation, query, internalMutation } from "./_generated/server";
2
+ import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
3
3
  import { internal } from "./_generated/api";
4
4
  import {
5
5
  bugSeverityValidator,
@@ -41,6 +41,7 @@ const bugReportReturnValidator = v.object({
41
41
  aiAnalyzedAt: v.optional(v.number()),
42
42
  aiThreadId: v.optional(v.string()),
43
43
  notificationsSentAt: v.optional(v.number()),
44
+ ticketNumber: v.optional(v.string()),
44
45
  });
45
46
 
46
47
  /**
@@ -66,11 +67,39 @@ export const create = mutation({
66
67
  viewportWidth: v.number(),
67
68
  viewportHeight: v.number(),
68
69
  networkState: v.string(),
70
+ // When true, skip auto-scheduling of AI analysis/email notifications
71
+ // Use this when the parent app will handle processing with its own API keys
72
+ skipAutoProcess: v.optional(v.boolean()),
73
+ // Optional API keys - pass from parent app since components don't inherit env vars
74
+ openRouterApiKey: v.optional(v.string()),
75
+ resendApiKey: v.optional(v.string()),
76
+ resendFromEmail: v.optional(v.string()),
69
77
  },
70
78
  returns: v.id("bugReports"),
71
79
  handler: async (ctx, args) => {
72
80
  const now = Date.now();
73
81
 
82
+ // Generate ticket number
83
+ const year = new Date().getFullYear();
84
+ const counter = await ctx.db
85
+ .query("ticketCounters")
86
+ .withIndex("by_type_year", (q) => q.eq("type", "bug").eq("year", year))
87
+ .unique();
88
+
89
+ let nextNumber: number;
90
+ if (counter) {
91
+ nextNumber = counter.nextNumber;
92
+ await ctx.db.patch(counter._id, { nextNumber: nextNumber + 1 });
93
+ } else {
94
+ nextNumber = 1;
95
+ await ctx.db.insert("ticketCounters", {
96
+ type: "bug",
97
+ year,
98
+ nextNumber: 2,
99
+ });
100
+ }
101
+ const ticketNumber = `BUG-${year}-${nextNumber.toString().padStart(4, "0")}`;
102
+
74
103
  const reportId = await ctx.db.insert("bugReports", {
75
104
  title: args.title,
76
105
  description: args.description,
@@ -88,19 +117,27 @@ export const create = mutation({
88
117
  viewportWidth: args.viewportWidth,
89
118
  viewportHeight: args.viewportHeight,
90
119
  networkState: args.networkState,
120
+ ticketNumber,
91
121
  createdAt: now,
92
122
  updatedAt: now,
93
123
  });
94
124
 
95
125
  // Schedule AI analysis and email notifications (runs immediately)
96
- // This uses a try-catch to gracefully handle if agents aren't configured
97
- try {
98
- await ctx.scheduler.runAfter(0, internal.agents.bugReportAgent.processBugReport, {
99
- bugReportId: reportId,
100
- });
101
- } catch {
102
- // AI agent not available, continue without analysis
103
- console.log("AI agent not configured, skipping analysis");
126
+ // Skip if parent app will handle processing with its own API keys
127
+ if (!args.skipAutoProcess) {
128
+ // This uses a try-catch to gracefully handle if agents aren't configured
129
+ try {
130
+ await ctx.scheduler.runAfter(0, internal.agents.bugReportAgent.processBugReport, {
131
+ bugReportId: reportId,
132
+ // Forward API keys from parent app (components don't inherit env vars)
133
+ openRouterApiKey: args.openRouterApiKey,
134
+ resendApiKey: args.resendApiKey,
135
+ resendFromEmail: args.resendFromEmail,
136
+ });
137
+ } catch {
138
+ // AI agent not available, continue without analysis
139
+ console.log("AI agent not configured, skipping analysis");
140
+ }
104
141
  }
105
142
 
106
143
  return reportId;
@@ -332,6 +369,108 @@ export const updateWithAnalysis = internalMutation({
332
369
  },
333
370
  });
334
371
 
372
+ /**
373
+ * Internal query to list bug reports (for HTTP API).
374
+ */
375
+ export const listInternal = internalQuery({
376
+ args: {
377
+ status: v.optional(bugStatusValidator),
378
+ severity: v.optional(bugSeverityValidator),
379
+ includeArchived: v.optional(v.boolean()),
380
+ limit: v.optional(v.number()),
381
+ },
382
+ returns: v.array(bugReportReturnValidator),
383
+ handler: async (ctx, args) => {
384
+ const limit = args.limit ?? 50;
385
+ const includeArchived = args.includeArchived ?? false;
386
+
387
+ let reports;
388
+ if (args.status) {
389
+ const status = args.status;
390
+ reports = await ctx.db
391
+ .query("bugReports")
392
+ .withIndex("by_status", (q) => q.eq("status", status))
393
+ .order("desc")
394
+ .take(limit * 2);
395
+ } else if (args.severity) {
396
+ const severity = args.severity;
397
+ reports = await ctx.db
398
+ .query("bugReports")
399
+ .withIndex("by_severity", (q) => q.eq("severity", severity))
400
+ .order("desc")
401
+ .take(limit * 2);
402
+ } else {
403
+ reports = await ctx.db
404
+ .query("bugReports")
405
+ .withIndex("by_created")
406
+ .order("desc")
407
+ .take(limit * 2);
408
+ }
409
+
410
+ if (!includeArchived) {
411
+ reports = reports.filter((r) => !r.isArchived);
412
+ }
413
+
414
+ return reports.slice(0, limit);
415
+ },
416
+ });
417
+
418
+ /**
419
+ * Internal mutation to update bug report status by ticket number (for HTTP API).
420
+ */
421
+ export const updateStatusByTicketNumber = internalMutation({
422
+ args: {
423
+ ticketNumber: v.string(),
424
+ status: bugStatusValidator,
425
+ },
426
+ returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string() }), v.null()),
427
+ handler: async (ctx, args) => {
428
+ const report = await ctx.db
429
+ .query("bugReports")
430
+ .withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber))
431
+ .unique();
432
+
433
+ if (!report) {
434
+ return null;
435
+ }
436
+
437
+ await ctx.db.patch(report._id, {
438
+ status: args.status,
439
+ updatedAt: Date.now(),
440
+ });
441
+
442
+ return { success: true, ticketNumber: args.ticketNumber };
443
+ },
444
+ });
445
+
446
+ /**
447
+ * Internal mutation to archive/unarchive bug report by ticket number (for HTTP API).
448
+ */
449
+ export const setArchivedByTicketNumber = internalMutation({
450
+ args: {
451
+ ticketNumber: v.string(),
452
+ isArchived: v.boolean(),
453
+ },
454
+ returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string(), isArchived: v.boolean() }), v.null()),
455
+ handler: async (ctx, args) => {
456
+ const report = await ctx.db
457
+ .query("bugReports")
458
+ .withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber))
459
+ .unique();
460
+
461
+ if (!report) {
462
+ return null;
463
+ }
464
+
465
+ await ctx.db.patch(report._id, {
466
+ isArchived: args.isArchived,
467
+ updatedAt: Date.now(),
468
+ });
469
+
470
+ return { success: true, ticketNumber: args.ticketNumber, isArchived: args.isArchived };
471
+ },
472
+ });
473
+
335
474
  /**
336
475
  * Internal mutation to mark notifications as sent
337
476
  */
@@ -347,6 +347,9 @@ export const sendBugReportNotifications = internalAction({
347
347
  }),
348
348
  v.null()
349
349
  ),
350
+ // Optional API keys - passed from parent app since components don't inherit env vars
351
+ resendApiKey: v.optional(v.string()),
352
+ resendFromEmail: v.optional(v.string()),
350
353
  },
351
354
  returns: v.object({
352
355
  success: v.boolean(),
@@ -363,8 +366,8 @@ export const sendBugReportNotifications = internalAction({
363
366
  teamEmailsSent: number;
364
367
  error?: string;
365
368
  }> => {
366
- // Check for Resend API key
367
- const resendApiKey = process.env.RESEND_API_KEY;
369
+ // Check for Resend API key - prefer args (from parent app), fallback to env
370
+ const resendApiKey = args.resendApiKey || process.env.RESEND_API_KEY;
368
371
  if (!resendApiKey) {
369
372
  console.warn("RESEND_API_KEY not configured. Skipping email notifications.");
370
373
  return {
@@ -376,7 +379,7 @@ export const sendBugReportNotifications = internalAction({
376
379
  }
377
380
 
378
381
  const resend = new Resend(resendApiKey);
379
- const fromEmail = process.env.RESEND_FROM_EMAIL || "bugs@resend.dev";
382
+ const fromEmail = args.resendFromEmail || process.env.RESEND_FROM_EMAIL || "bugs@resend.dev";
380
383
 
381
384
  // Get bug report data
382
385
  const report = await ctx.runQuery(
@@ -328,6 +328,9 @@ export const sendFeedbackNotifications = internalAction({
328
328
  }),
329
329
  v.null()
330
330
  ),
331
+ // Optional API keys - passed from parent app since components don't inherit env vars
332
+ resendApiKey: v.optional(v.string()),
333
+ resendFromEmail: v.optional(v.string()),
331
334
  },
332
335
  returns: v.object({
333
336
  success: v.boolean(),
@@ -344,8 +347,8 @@ export const sendFeedbackNotifications = internalAction({
344
347
  teamEmailsSent: number;
345
348
  error?: string;
346
349
  }> => {
347
- // Check for Resend API key
348
- const resendApiKey = process.env.RESEND_API_KEY;
350
+ // Check for Resend API key - prefer args (from parent app), fallback to env
351
+ const resendApiKey = args.resendApiKey || process.env.RESEND_API_KEY;
349
352
  if (!resendApiKey) {
350
353
  console.warn("RESEND_API_KEY not configured. Skipping email notifications.");
351
354
  return {
@@ -357,7 +360,7 @@ export const sendFeedbackNotifications = internalAction({
357
360
  }
358
361
 
359
362
  const resend = new Resend(resendApiKey);
360
- const fromEmail = process.env.RESEND_FROM_EMAIL || "feedback@resend.dev";
363
+ const fromEmail = args.resendFromEmail || process.env.RESEND_FROM_EMAIL || "feedback@resend.dev";
361
364
 
362
365
  // Get feedback data
363
366
  const feedback = await ctx.runQuery(internal.emails.feedbackEmails.getFeedbackForEmail, {
@@ -1,5 +1,5 @@
1
1
  import { v } from "convex/values";
2
- import { mutation, query, internalMutation } from "./_generated/server";
2
+ import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
3
3
  import { internal } from "./_generated/api";
4
4
  import {
5
5
  feedbackTypeValidator,
@@ -42,6 +42,7 @@ const feedbackReturnValidator = v.object({
42
42
  aiAnalyzedAt: v.optional(v.number()),
43
43
  aiThreadId: v.optional(v.string()),
44
44
  notificationsSentAt: v.optional(v.number()),
45
+ ticketNumber: v.optional(v.string()),
45
46
  });
46
47
 
47
48
  /**
@@ -68,11 +69,39 @@ export const create = mutation({
68
69
  viewportWidth: v.number(),
69
70
  viewportHeight: v.number(),
70
71
  networkState: v.string(),
72
+ // When true, skip auto-scheduling of AI analysis/email notifications
73
+ // Use this when the parent app will handle processing with its own API keys
74
+ skipAutoProcess: v.optional(v.boolean()),
75
+ // Optional API keys - pass from parent app since components don't inherit env vars
76
+ openRouterApiKey: v.optional(v.string()),
77
+ resendApiKey: v.optional(v.string()),
78
+ resendFromEmail: v.optional(v.string()),
71
79
  },
72
80
  returns: v.id("feedback"),
73
81
  handler: async (ctx, args) => {
74
82
  const now = Date.now();
75
83
 
84
+ // Generate ticket number
85
+ const year = new Date().getFullYear();
86
+ const counter = await ctx.db
87
+ .query("ticketCounters")
88
+ .withIndex("by_type_year", (q) => q.eq("type", "feedback").eq("year", year))
89
+ .unique();
90
+
91
+ let nextNumber: number;
92
+ if (counter) {
93
+ nextNumber = counter.nextNumber;
94
+ await ctx.db.patch(counter._id, { nextNumber: nextNumber + 1 });
95
+ } else {
96
+ nextNumber = 1;
97
+ await ctx.db.insert("ticketCounters", {
98
+ type: "feedback",
99
+ year,
100
+ nextNumber: 2,
101
+ });
102
+ }
103
+ const ticketNumber = `FB-${year}-${nextNumber.toString().padStart(4, "0")}`;
104
+
76
105
  const feedbackId = await ctx.db.insert("feedback", {
77
106
  type: args.type,
78
107
  title: args.title,
@@ -91,18 +120,26 @@ export const create = mutation({
91
120
  viewportWidth: args.viewportWidth,
92
121
  viewportHeight: args.viewportHeight,
93
122
  networkState: args.networkState,
123
+ ticketNumber,
94
124
  createdAt: now,
95
125
  updatedAt: now,
96
126
  });
97
127
 
98
128
  // Schedule AI analysis and email notifications (runs immediately)
99
- try {
100
- await ctx.scheduler.runAfter(0, internal.agents.feedbackAgent.processFeedback, {
101
- feedbackId,
102
- });
103
- } catch {
104
- // AI agent not available, continue without analysis
105
- console.log("AI agent not configured, skipping analysis");
129
+ // Skip if parent app will handle processing with its own API keys
130
+ if (!args.skipAutoProcess) {
131
+ try {
132
+ await ctx.scheduler.runAfter(0, internal.agents.feedbackAgent.processFeedback, {
133
+ feedbackId,
134
+ // Forward API keys from parent app (components don't inherit env vars)
135
+ openRouterApiKey: args.openRouterApiKey,
136
+ resendApiKey: args.resendApiKey,
137
+ resendFromEmail: args.resendFromEmail,
138
+ });
139
+ } catch {
140
+ // AI agent not available, continue without analysis
141
+ console.log("AI agent not configured, skipping analysis");
142
+ }
106
143
  }
107
144
 
108
145
  return feedbackId;
@@ -354,3 +391,113 @@ export const markNotificationsSent = internalMutation({
354
391
  return null;
355
392
  },
356
393
  });
394
+
395
+ /**
396
+ * Internal query to list feedback (for HTTP API).
397
+ */
398
+ export const listInternal = internalQuery({
399
+ args: {
400
+ status: v.optional(feedbackStatusValidator),
401
+ type: v.optional(feedbackTypeValidator),
402
+ priority: v.optional(feedbackPriorityValidator),
403
+ includeArchived: v.optional(v.boolean()),
404
+ limit: v.optional(v.number()),
405
+ },
406
+ returns: v.array(feedbackReturnValidator),
407
+ handler: async (ctx, args) => {
408
+ const limit = args.limit ?? 50;
409
+ const includeArchived = args.includeArchived ?? false;
410
+
411
+ let results;
412
+ if (args.status) {
413
+ const status = args.status;
414
+ results = await ctx.db
415
+ .query("feedback")
416
+ .withIndex("by_status", (q) => q.eq("status", status))
417
+ .order("desc")
418
+ .take(limit * 2);
419
+ } else if (args.type) {
420
+ const type = args.type;
421
+ results = await ctx.db
422
+ .query("feedback")
423
+ .withIndex("by_type", (q) => q.eq("type", type))
424
+ .order("desc")
425
+ .take(limit * 2);
426
+ } else if (args.priority) {
427
+ const priority = args.priority;
428
+ results = await ctx.db
429
+ .query("feedback")
430
+ .withIndex("by_priority", (q) => q.eq("priority", priority))
431
+ .order("desc")
432
+ .take(limit * 2);
433
+ } else {
434
+ results = await ctx.db
435
+ .query("feedback")
436
+ .withIndex("by_created")
437
+ .order("desc")
438
+ .take(limit * 2);
439
+ }
440
+
441
+ if (!includeArchived) {
442
+ results = results.filter((r) => !r.isArchived);
443
+ }
444
+
445
+ return results.slice(0, limit);
446
+ },
447
+ });
448
+
449
+ /**
450
+ * Internal mutation to archive/unarchive feedback by ticket number (for HTTP API).
451
+ */
452
+ export const setArchivedByTicketNumber = internalMutation({
453
+ args: {
454
+ ticketNumber: v.string(),
455
+ isArchived: v.boolean(),
456
+ },
457
+ returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string(), isArchived: v.boolean() }), v.null()),
458
+ handler: async (ctx, args) => {
459
+ const feedback = await ctx.db
460
+ .query("feedback")
461
+ .withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber))
462
+ .unique();
463
+
464
+ if (!feedback) {
465
+ return null;
466
+ }
467
+
468
+ await ctx.db.patch(feedback._id, {
469
+ isArchived: args.isArchived,
470
+ updatedAt: Date.now(),
471
+ });
472
+
473
+ return { success: true, ticketNumber: args.ticketNumber, isArchived: args.isArchived };
474
+ },
475
+ });
476
+
477
+ /**
478
+ * Internal mutation to update feedback status by ticket number (for HTTP API).
479
+ */
480
+ export const updateStatusByTicketNumber = internalMutation({
481
+ args: {
482
+ ticketNumber: v.string(),
483
+ status: feedbackStatusValidator,
484
+ },
485
+ returns: v.union(v.object({ success: v.boolean(), ticketNumber: v.string() }), v.null()),
486
+ handler: async (ctx, args) => {
487
+ const feedback = await ctx.db
488
+ .query("feedback")
489
+ .withIndex("by_ticket_number", (q) => q.eq("ticketNumber", args.ticketNumber))
490
+ .unique();
491
+
492
+ if (!feedback) {
493
+ return null;
494
+ }
495
+
496
+ await ctx.db.patch(feedback._id, {
497
+ status: args.status,
498
+ updatedAt: Date.now(),
499
+ });
500
+
501
+ return { success: true, ticketNumber: args.ticketNumber };
502
+ },
503
+ });