@fatagnus/convex-feedback 0.2.7 → 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
  /**
@@ -78,6 +79,27 @@ export const create = mutation({
78
79
  handler: async (ctx, args) => {
79
80
  const now = Date.now();
80
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
+
81
103
  const reportId = await ctx.db.insert("bugReports", {
82
104
  title: args.title,
83
105
  description: args.description,
@@ -95,6 +117,7 @@ export const create = mutation({
95
117
  viewportWidth: args.viewportWidth,
96
118
  viewportHeight: args.viewportHeight,
97
119
  networkState: args.networkState,
120
+ ticketNumber,
98
121
  createdAt: now,
99
122
  updatedAt: now,
100
123
  });
@@ -346,6 +369,108 @@ export const updateWithAnalysis = internalMutation({
346
369
  },
347
370
  });
348
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
+
349
474
  /**
350
475
  * Internal mutation to mark notifications as sent
351
476
  */
@@ -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
  /**
@@ -80,6 +81,27 @@ export const create = mutation({
80
81
  handler: async (ctx, args) => {
81
82
  const now = Date.now();
82
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
+
83
105
  const feedbackId = await ctx.db.insert("feedback", {
84
106
  type: args.type,
85
107
  title: args.title,
@@ -98,6 +120,7 @@ export const create = mutation({
98
120
  viewportWidth: args.viewportWidth,
99
121
  viewportHeight: args.viewportHeight,
100
122
  networkState: args.networkState,
123
+ ticketNumber,
101
124
  createdAt: now,
102
125
  updatedAt: now,
103
126
  });
@@ -368,3 +391,113 @@ export const markNotificationsSent = internalMutation({
368
391
  return null;
369
392
  },
370
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
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Unit tests for HTTP API - Ticket Number Format Validation
3
+ *
4
+ * Note: Testing Convex component packages with convex-test requires special setup
5
+ * due to the @convex-dev/agent dependency. These tests verify ticket number formats
6
+ * used by the HTTP API endpoints.
7
+ *
8
+ * For full integration testing of queries/mutations, run the component in a real
9
+ * Convex deployment or see the main project's test patterns.
10
+ *
11
+ * Prompt generation tests are in prompts.test.ts
12
+ * API key format tests are in apiKeys.test.ts
13
+ */
14
+ import { describe, it, expect } from "vitest";
15
+
16
+ describe("HTTP API - Ticket Number Format", () => {
17
+ describe("Bug Report Ticket Numbers", () => {
18
+ it("follows BUG-YYYY-NNNN format", () => {
19
+ const validPattern = /^BUG-\d{4}-\d{4}$/;
20
+
21
+ expect("BUG-2025-0001").toMatch(validPattern);
22
+ expect("BUG-2025-0123").toMatch(validPattern);
23
+ expect("BUG-2024-9999").toMatch(validPattern);
24
+ });
25
+
26
+ it("pads sequence number to 4 digits", () => {
27
+ const formatTicketNumber = (year: number, seq: number) =>
28
+ `BUG-${year}-${seq.toString().padStart(4, "0")}`;
29
+
30
+ expect(formatTicketNumber(2025, 1)).toBe("BUG-2025-0001");
31
+ expect(formatTicketNumber(2025, 42)).toBe("BUG-2025-0042");
32
+ expect(formatTicketNumber(2025, 999)).toBe("BUG-2025-0999");
33
+ expect(formatTicketNumber(2025, 1234)).toBe("BUG-2025-1234");
34
+ });
35
+ });
36
+
37
+ describe("Feedback Ticket Numbers", () => {
38
+ it("follows FB-YYYY-NNNN format", () => {
39
+ const validPattern = /^FB-\d{4}-\d{4}$/;
40
+
41
+ expect("FB-2025-0001").toMatch(validPattern);
42
+ expect("FB-2025-0123").toMatch(validPattern);
43
+ expect("FB-2024-9999").toMatch(validPattern);
44
+ });
45
+
46
+ it("pads sequence number to 4 digits", () => {
47
+ const formatTicketNumber = (year: number, seq: number) =>
48
+ `FB-${year}-${seq.toString().padStart(4, "0")}`;
49
+
50
+ expect(formatTicketNumber(2025, 1)).toBe("FB-2025-0001");
51
+ expect(formatTicketNumber(2025, 42)).toBe("FB-2025-0042");
52
+ expect(formatTicketNumber(2025, 999)).toBe("FB-2025-0999");
53
+ expect(formatTicketNumber(2025, 1234)).toBe("FB-2025-1234");
54
+ });
55
+ });
56
+
57
+ describe("Ticket Number Parsing", () => {
58
+ it("can identify bug tickets by prefix", () => {
59
+ const ticketNumber = "BUG-2025-0001";
60
+ expect(ticketNumber.startsWith("BUG-")).toBe(true);
61
+ expect(ticketNumber.startsWith("FB-")).toBe(false);
62
+ });
63
+
64
+ it("can identify feedback tickets by prefix", () => {
65
+ const ticketNumber = "FB-2025-0001";
66
+ expect(ticketNumber.startsWith("FB-")).toBe(true);
67
+ expect(ticketNumber.startsWith("BUG-")).toBe(false);
68
+ });
69
+
70
+ it("normalizes ticket numbers to uppercase", () => {
71
+ const lowercase = "bug-2025-0001";
72
+ const normalized = lowercase.toUpperCase();
73
+ expect(normalized).toBe("BUG-2025-0001");
74
+ });
75
+ });
76
+ });