swarm-tickets 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,552 @@
1
+ /**
2
+ * SQLite Storage Adapter
3
+ * Uses better-sqlite3 for synchronous, high-performance SQLite operations
4
+ */
5
+
6
+ const path = require('path');
7
+ const BaseAdapter = require('./base-adapter');
8
+
9
+ class SqliteAdapter extends BaseAdapter {
10
+ constructor(config) {
11
+ super(config);
12
+ this.dbPath = path.resolve(config.sqlitePath || './tickets.db');
13
+ this.db = null;
14
+ }
15
+
16
+ async initialize() {
17
+ // Dynamic import to make sqlite optional
18
+ let Database;
19
+ try {
20
+ Database = require('better-sqlite3');
21
+ } catch (error) {
22
+ throw new Error(
23
+ 'SQLite adapter requires better-sqlite3. Install it with: npm install better-sqlite3'
24
+ );
25
+ }
26
+
27
+ this.db = new Database(this.dbPath);
28
+
29
+ // Enable foreign keys
30
+ this.db.pragma('foreign_keys = ON');
31
+
32
+ // Create tables
33
+ this.db.exec(`
34
+ -- Main tickets table
35
+ CREATE TABLE IF NOT EXISTS tickets (
36
+ id TEXT PRIMARY KEY,
37
+ route TEXT NOT NULL,
38
+ f12Errors TEXT DEFAULT '',
39
+ serverErrors TEXT DEFAULT '',
40
+ description TEXT DEFAULT '',
41
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in-progress', 'fixed', 'closed')),
42
+ priority TEXT CHECK (priority IS NULL OR priority IN ('critical', 'high', 'medium', 'low')),
43
+ namespace TEXT,
44
+ createdAt TEXT NOT NULL,
45
+ updatedAt TEXT NOT NULL
46
+ );
47
+
48
+ -- Related tickets junction table
49
+ CREATE TABLE IF NOT EXISTS ticket_relations (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ ticket_id TEXT NOT NULL,
52
+ related_ticket_id TEXT NOT NULL,
53
+ FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
54
+ FOREIGN KEY (related_ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
55
+ UNIQUE(ticket_id, related_ticket_id)
56
+ );
57
+
58
+ -- Swarm actions log
59
+ CREATE TABLE IF NOT EXISTS swarm_actions (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ ticket_id TEXT NOT NULL,
62
+ timestamp TEXT NOT NULL,
63
+ action TEXT NOT NULL,
64
+ result TEXT,
65
+ FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
66
+ );
67
+
68
+ -- Comments table
69
+ CREATE TABLE IF NOT EXISTS comments (
70
+ id TEXT PRIMARY KEY,
71
+ ticket_id TEXT NOT NULL,
72
+ timestamp TEXT NOT NULL,
73
+ type TEXT NOT NULL DEFAULT 'human' CHECK (type IN ('human', 'ai')),
74
+ author TEXT DEFAULT 'anonymous',
75
+ content TEXT DEFAULT '',
76
+ metadata TEXT DEFAULT '{}',
77
+ editedAt TEXT,
78
+ FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
79
+ );
80
+
81
+ -- API Keys for bug report widget
82
+ CREATE TABLE IF NOT EXISTS api_keys (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ key TEXT UNIQUE NOT NULL,
85
+ name TEXT,
86
+ created_at TEXT NOT NULL,
87
+ last_used TEXT,
88
+ rate_limit INTEGER DEFAULT 100,
89
+ enabled INTEGER DEFAULT 1
90
+ );
91
+
92
+ -- Rate limiting table
93
+ CREATE TABLE IF NOT EXISTS rate_limits (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ identifier TEXT NOT NULL,
96
+ window_start TEXT NOT NULL,
97
+ request_count INTEGER DEFAULT 1,
98
+ UNIQUE(identifier, window_start)
99
+ );
100
+
101
+ -- Create indexes
102
+ CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
103
+ CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
104
+ CREATE INDEX IF NOT EXISTS idx_tickets_route ON tickets(route);
105
+ CREATE INDEX IF NOT EXISTS idx_tickets_createdAt ON tickets(createdAt);
106
+ CREATE INDEX IF NOT EXISTS idx_swarm_actions_ticket_id ON swarm_actions(ticket_id);
107
+ CREATE INDEX IF NOT EXISTS idx_comments_ticket_id ON comments(ticket_id);
108
+ CREATE INDEX IF NOT EXISTS idx_rate_limits_identifier ON rate_limits(identifier);
109
+ `);
110
+
111
+ // Prepare commonly used statements
112
+ this._prepareStatements();
113
+ }
114
+
115
+ _prepareStatements() {
116
+ this.stmts = {
117
+ getAllTickets: this.db.prepare('SELECT * FROM tickets ORDER BY createdAt DESC'),
118
+ getTicket: this.db.prepare('SELECT * FROM tickets WHERE id = ?'),
119
+ insertTicket: this.db.prepare(`
120
+ INSERT INTO tickets (id, route, f12Errors, serverErrors, description, status, priority, namespace, createdAt, updatedAt)
121
+ VALUES (@id, @route, @f12Errors, @serverErrors, @description, @status, @priority, @namespace, @createdAt, @updatedAt)
122
+ `),
123
+ updateTicket: this.db.prepare(`
124
+ UPDATE tickets SET
125
+ route = COALESCE(@route, route),
126
+ f12Errors = COALESCE(@f12Errors, f12Errors),
127
+ serverErrors = COALESCE(@serverErrors, serverErrors),
128
+ description = COALESCE(@description, description),
129
+ status = COALESCE(@status, status),
130
+ priority = @priority,
131
+ namespace = @namespace,
132
+ updatedAt = @updatedAt
133
+ WHERE id = @id
134
+ `),
135
+ deleteTicket: this.db.prepare('DELETE FROM tickets WHERE id = ?'),
136
+
137
+ // Relations
138
+ getRelatedTickets: this.db.prepare('SELECT related_ticket_id FROM ticket_relations WHERE ticket_id = ?'),
139
+ insertRelation: this.db.prepare('INSERT OR IGNORE INTO ticket_relations (ticket_id, related_ticket_id) VALUES (?, ?)'),
140
+ deleteRelations: this.db.prepare('DELETE FROM ticket_relations WHERE ticket_id = ?'),
141
+
142
+ // Swarm actions
143
+ getSwarmActions: this.db.prepare('SELECT * FROM swarm_actions WHERE ticket_id = ? ORDER BY timestamp ASC'),
144
+ insertSwarmAction: this.db.prepare(`
145
+ INSERT INTO swarm_actions (ticket_id, timestamp, action, result)
146
+ VALUES (@ticket_id, @timestamp, @action, @result)
147
+ `),
148
+
149
+ // Comments
150
+ getComments: this.db.prepare('SELECT * FROM comments WHERE ticket_id = ? ORDER BY timestamp ASC'),
151
+ getComment: this.db.prepare('SELECT * FROM comments WHERE id = ? AND ticket_id = ?'),
152
+ insertComment: this.db.prepare(`
153
+ INSERT INTO comments (id, ticket_id, timestamp, type, author, content, metadata)
154
+ VALUES (@id, @ticket_id, @timestamp, @type, @author, @content, @metadata)
155
+ `),
156
+ updateComment: this.db.prepare(`
157
+ UPDATE comments SET content = @content, metadata = @metadata, editedAt = @editedAt
158
+ WHERE id = @id AND ticket_id = @ticket_id
159
+ `),
160
+ deleteComment: this.db.prepare('DELETE FROM comments WHERE id = ? AND ticket_id = ?'),
161
+
162
+ // Stats
163
+ countByStatus: this.db.prepare('SELECT status, COUNT(*) as count FROM tickets GROUP BY status'),
164
+ countByPriority: this.db.prepare('SELECT priority, COUNT(*) as count FROM tickets GROUP BY priority'),
165
+
166
+ // API Keys
167
+ getApiKey: this.db.prepare('SELECT * FROM api_keys WHERE key = ? AND enabled = 1'),
168
+ updateApiKeyUsage: this.db.prepare('UPDATE api_keys SET last_used = ? WHERE key = ?'),
169
+
170
+ // Rate limiting
171
+ getRateLimit: this.db.prepare('SELECT * FROM rate_limits WHERE identifier = ? AND window_start = ?'),
172
+ upsertRateLimit: this.db.prepare(`
173
+ INSERT INTO rate_limits (identifier, window_start, request_count)
174
+ VALUES (@identifier, @window_start, 1)
175
+ ON CONFLICT(identifier, window_start) DO UPDATE SET request_count = request_count + 1
176
+ `),
177
+ cleanOldRateLimits: this.db.prepare('DELETE FROM rate_limits WHERE window_start < ?')
178
+ };
179
+ }
180
+
181
+ async close() {
182
+ if (this.db) {
183
+ this.db.close();
184
+ this.db = null;
185
+ }
186
+ }
187
+
188
+ // Helper to build full ticket object with relations and actions
189
+ _buildFullTicket(row) {
190
+ if (!row) return null;
191
+
192
+ const relatedRows = this.stmts.getRelatedTickets.all(row.id);
193
+ const swarmActions = this.stmts.getSwarmActions.all(row.id);
194
+ const comments = this.stmts.getComments.all(row.id);
195
+
196
+ return {
197
+ ...row,
198
+ relatedTickets: relatedRows.map(r => r.related_ticket_id),
199
+ swarmActions: swarmActions.map(a => ({
200
+ timestamp: a.timestamp,
201
+ action: a.action,
202
+ result: a.result
203
+ })),
204
+ comments: comments.map(c => ({
205
+ id: c.id,
206
+ timestamp: c.timestamp,
207
+ type: c.type,
208
+ author: c.author,
209
+ content: c.content,
210
+ metadata: JSON.parse(c.metadata || '{}'),
211
+ editedAt: c.editedAt
212
+ }))
213
+ };
214
+ }
215
+
216
+ // ==================== TICKET OPERATIONS ====================
217
+
218
+ async getAllTickets(filters = {}) {
219
+ let query = 'SELECT * FROM tickets WHERE 1=1';
220
+ const params = [];
221
+
222
+ if (filters.status) {
223
+ query += ' AND status = ?';
224
+ params.push(filters.status);
225
+ }
226
+ if (filters.priority) {
227
+ query += ' AND priority = ?';
228
+ params.push(filters.priority);
229
+ }
230
+ if (filters.route) {
231
+ query += ' AND route LIKE ?';
232
+ params.push(`%${filters.route}%`);
233
+ }
234
+
235
+ query += ' ORDER BY createdAt DESC';
236
+
237
+ const rows = this.db.prepare(query).all(...params);
238
+ return rows.map(row => this._buildFullTicket(row));
239
+ }
240
+
241
+ async getTicket(id) {
242
+ const row = this.stmts.getTicket.get(id);
243
+ return this._buildFullTicket(row);
244
+ }
245
+
246
+ async createTicket(ticketData) {
247
+ const id = this.generateTicketId();
248
+ const now = new Date().toISOString();
249
+
250
+ const ticket = {
251
+ id,
252
+ route: ticketData.route || '',
253
+ f12Errors: ticketData.f12Errors || '',
254
+ serverErrors: ticketData.serverErrors || '',
255
+ description: ticketData.description || '',
256
+ status: ticketData.status || 'open',
257
+ priority: ticketData.priority || null,
258
+ namespace: ticketData.namespace || null,
259
+ createdAt: now,
260
+ updatedAt: now
261
+ };
262
+
263
+ const transaction = this.db.transaction(() => {
264
+ this.stmts.insertTicket.run(ticket);
265
+
266
+ // Insert related tickets
267
+ if (ticketData.relatedTickets && ticketData.relatedTickets.length > 0) {
268
+ for (const relatedId of ticketData.relatedTickets) {
269
+ this.stmts.insertRelation.run(id, relatedId);
270
+ }
271
+ }
272
+
273
+ // Insert swarm actions
274
+ if (ticketData.swarmActions && ticketData.swarmActions.length > 0) {
275
+ for (const action of ticketData.swarmActions) {
276
+ this.stmts.insertSwarmAction.run({
277
+ ticket_id: id,
278
+ timestamp: action.timestamp || now,
279
+ action: action.action,
280
+ result: action.result || null
281
+ });
282
+ }
283
+ }
284
+
285
+ // Insert comments
286
+ if (ticketData.comments && ticketData.comments.length > 0) {
287
+ for (const comment of ticketData.comments) {
288
+ this.stmts.insertComment.run({
289
+ id: comment.id || this.generateCommentId(),
290
+ ticket_id: id,
291
+ timestamp: comment.timestamp || now,
292
+ type: comment.type || 'human',
293
+ author: comment.author || 'anonymous',
294
+ content: comment.content || '',
295
+ metadata: JSON.stringify(comment.metadata || {})
296
+ });
297
+ }
298
+ }
299
+ });
300
+
301
+ transaction();
302
+ return this.getTicket(id);
303
+ }
304
+
305
+ async updateTicket(id, updates) {
306
+ const existing = await this.getTicket(id);
307
+ if (!existing) return null;
308
+
309
+ const now = new Date().toISOString();
310
+
311
+ const transaction = this.db.transaction(() => {
312
+ this.stmts.updateTicket.run({
313
+ id,
314
+ route: updates.route,
315
+ f12Errors: updates.f12Errors,
316
+ serverErrors: updates.serverErrors,
317
+ description: updates.description,
318
+ status: updates.status,
319
+ priority: updates.priority !== undefined ? updates.priority : existing.priority,
320
+ namespace: updates.namespace !== undefined ? updates.namespace : existing.namespace,
321
+ updatedAt: now
322
+ });
323
+
324
+ // Update related tickets if provided
325
+ if (updates.relatedTickets !== undefined) {
326
+ this.stmts.deleteRelations.run(id);
327
+ for (const relatedId of updates.relatedTickets) {
328
+ this.stmts.insertRelation.run(id, relatedId);
329
+ }
330
+ }
331
+ });
332
+
333
+ transaction();
334
+ return this.getTicket(id);
335
+ }
336
+
337
+ async deleteTicket(id) {
338
+ const result = this.stmts.deleteTicket.run(id);
339
+ return result.changes > 0;
340
+ }
341
+
342
+ // ==================== SWARM ACTION OPERATIONS ====================
343
+
344
+ async addSwarmAction(ticketId, action) {
345
+ const ticket = await this.getTicket(ticketId);
346
+ if (!ticket) return null;
347
+
348
+ const now = new Date().toISOString();
349
+
350
+ this.stmts.insertSwarmAction.run({
351
+ ticket_id: ticketId,
352
+ timestamp: now,
353
+ action: action.action,
354
+ result: action.result || null
355
+ });
356
+
357
+ this.db.prepare('UPDATE tickets SET updatedAt = ? WHERE id = ?').run(now, ticketId);
358
+
359
+ return this.getTicket(ticketId);
360
+ }
361
+
362
+ // ==================== COMMENT OPERATIONS ====================
363
+
364
+ async addComment(ticketId, commentData) {
365
+ const ticket = await this.getTicket(ticketId);
366
+ if (!ticket) return null;
367
+
368
+ const now = new Date().toISOString();
369
+ const commentId = this.generateCommentId();
370
+
371
+ const comment = {
372
+ id: commentId,
373
+ ticket_id: ticketId,
374
+ timestamp: now,
375
+ type: commentData.type || 'human',
376
+ author: commentData.author || 'anonymous',
377
+ content: commentData.content || '',
378
+ metadata: JSON.stringify(commentData.metadata || {})
379
+ };
380
+
381
+ this.stmts.insertComment.run(comment);
382
+ this.db.prepare('UPDATE tickets SET updatedAt = ? WHERE id = ?').run(now, ticketId);
383
+
384
+ return {
385
+ id: commentId,
386
+ timestamp: now,
387
+ type: comment.type,
388
+ author: comment.author,
389
+ content: comment.content,
390
+ metadata: commentData.metadata || {}
391
+ };
392
+ }
393
+
394
+ async getComments(ticketId) {
395
+ const rows = this.stmts.getComments.all(ticketId);
396
+ return rows.map(c => ({
397
+ id: c.id,
398
+ timestamp: c.timestamp,
399
+ type: c.type,
400
+ author: c.author,
401
+ content: c.content,
402
+ metadata: JSON.parse(c.metadata || '{}'),
403
+ editedAt: c.editedAt
404
+ }));
405
+ }
406
+
407
+ async updateComment(ticketId, commentId, updates) {
408
+ const existing = this.stmts.getComment.get(commentId, ticketId);
409
+ if (!existing) return null;
410
+
411
+ const now = new Date().toISOString();
412
+
413
+ this.stmts.updateComment.run({
414
+ id: commentId,
415
+ ticket_id: ticketId,
416
+ content: updates.content !== undefined ? updates.content : existing.content,
417
+ metadata: JSON.stringify(updates.metadata || JSON.parse(existing.metadata || '{}')),
418
+ editedAt: now
419
+ });
420
+
421
+ this.db.prepare('UPDATE tickets SET updatedAt = ? WHERE id = ?').run(now, ticketId);
422
+
423
+ const updated = this.stmts.getComment.get(commentId, ticketId);
424
+ return {
425
+ id: updated.id,
426
+ timestamp: updated.timestamp,
427
+ type: updated.type,
428
+ author: updated.author,
429
+ content: updated.content,
430
+ metadata: JSON.parse(updated.metadata || '{}'),
431
+ editedAt: updated.editedAt
432
+ };
433
+ }
434
+
435
+ async deleteComment(ticketId, commentId) {
436
+ const result = this.stmts.deleteComment.run(commentId, ticketId);
437
+ if (result.changes > 0) {
438
+ const now = new Date().toISOString();
439
+ this.db.prepare('UPDATE tickets SET updatedAt = ? WHERE id = ?').run(now, ticketId);
440
+ return true;
441
+ }
442
+ return false;
443
+ }
444
+
445
+ // ==================== STATS OPERATIONS ====================
446
+
447
+ async getStats() {
448
+ const totalRow = this.db.prepare('SELECT COUNT(*) as total FROM tickets').get();
449
+ const statusRows = this.stmts.countByStatus.all();
450
+ const priorityRows = this.stmts.countByPriority.all();
451
+
452
+ const byStatus = { open: 0, inProgress: 0, fixed: 0, closed: 0 };
453
+ const byPriority = { critical: 0, high: 0, medium: 0, low: 0 };
454
+
455
+ statusRows.forEach(row => {
456
+ if (row.status === 'in-progress') byStatus.inProgress = row.count;
457
+ else if (byStatus[row.status] !== undefined) byStatus[row.status] = row.count;
458
+ });
459
+
460
+ priorityRows.forEach(row => {
461
+ if (row.priority && byPriority[row.priority] !== undefined) {
462
+ byPriority[row.priority] = row.count;
463
+ }
464
+ });
465
+
466
+ return {
467
+ total: totalRow.total,
468
+ byStatus,
469
+ byPriority
470
+ };
471
+ }
472
+
473
+ // ==================== BUG REPORT OPERATIONS ====================
474
+
475
+ async createBugReport(reportData, apiKey = null) {
476
+ // Validate API key if provided
477
+ if (apiKey) {
478
+ const keyRecord = this.stmts.getApiKey.get(apiKey);
479
+ if (!keyRecord) {
480
+ throw new Error('Invalid API key');
481
+ }
482
+ // Update last used
483
+ this.stmts.updateApiKeyUsage.run(new Date().toISOString(), apiKey);
484
+ }
485
+
486
+ // Check rate limit (IP-based if no API key)
487
+ const identifier = apiKey || reportData.ip || 'anonymous';
488
+ const windowStart = new Date();
489
+ windowStart.setMinutes(0, 0, 0); // Hour window
490
+ const windowKey = windowStart.toISOString();
491
+
492
+ // Clean old rate limit records
493
+ const oldWindow = new Date(Date.now() - 3600000).toISOString();
494
+ this.stmts.cleanOldRateLimits.run(oldWindow);
495
+
496
+ // Check current count
497
+ const rateLimit = this.stmts.getRateLimit.get(identifier, windowKey);
498
+ const limit = apiKey ? 1000 : 10; // Higher limit for API key users
499
+
500
+ if (rateLimit && rateLimit.request_count >= limit) {
501
+ throw new Error('Rate limit exceeded. Please try again later.');
502
+ }
503
+
504
+ // Increment rate limit
505
+ this.stmts.upsertRateLimit.run({ identifier, window_start: windowKey });
506
+
507
+ // Create the ticket
508
+ const ticket = await this.createTicket({
509
+ route: reportData.location || reportData.route || 'unknown',
510
+ f12Errors: reportData.clientError || reportData.f12Errors || '',
511
+ serverErrors: '',
512
+ description: reportData.description || '',
513
+ status: 'open',
514
+ priority: null,
515
+ swarmActions: [{
516
+ timestamp: new Date().toISOString(),
517
+ action: 'bug-report-submitted',
518
+ result: `Bug report submitted via widget. User agent: ${reportData.userAgent || 'unknown'}`
519
+ }]
520
+ });
521
+
522
+ return {
523
+ id: ticket.id,
524
+ status: 'submitted',
525
+ message: 'Bug report received. Thank you!'
526
+ };
527
+ }
528
+
529
+ // ==================== API KEY MANAGEMENT ====================
530
+
531
+ async createApiKey(name = null) {
532
+ const key = 'stk_' + require('crypto').randomBytes(24).toString('hex');
533
+ const now = new Date().toISOString();
534
+
535
+ this.db.prepare(`
536
+ INSERT INTO api_keys (key, name, created_at) VALUES (?, ?, ?)
537
+ `).run(key, name, now);
538
+
539
+ return { key, name, createdAt: now };
540
+ }
541
+
542
+ async listApiKeys() {
543
+ return this.db.prepare('SELECT id, name, created_at, last_used, enabled FROM api_keys').all();
544
+ }
545
+
546
+ async revokeApiKey(key) {
547
+ const result = this.db.prepare('UPDATE api_keys SET enabled = 0 WHERE key = ?').run(key);
548
+ return result.changes > 0;
549
+ }
550
+ }
551
+
552
+ module.exports = SqliteAdapter;