@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.
- package/README.md +346 -4
- package/package.json +12 -5
- package/src/convex/_generated/api.ts +1 -0
- package/src/convex/agents/feedbackInterviewAgent.ts +6 -12
- package/src/convex/apiKeys.test.ts +79 -0
- package/src/convex/apiKeys.ts +223 -0
- package/src/convex/bugReports.ts +126 -1
- package/src/convex/feedback.ts +134 -1
- package/src/convex/http.test.ts +76 -0
- package/src/convex/http.ts +630 -0
- package/src/convex/index.ts +11 -0
- package/src/convex/prompts.test.ts +185 -0
- package/src/convex/prompts.ts +605 -0
- package/src/convex/schema.ts +52 -2
- package/src/convex/ticketNumbers.ts +4 -0
- package/src/convex/tsconfig.json +24 -0
- package/src/index.ts +33 -1
- package/src/types.ts +38 -0
|
@@ -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
|
+
});
|
package/src/convex/bugReports.ts
CHANGED
|
@@ -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
|
*/
|
package/src/convex/feedback.ts
CHANGED
|
@@ -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
|
+
});
|