swarm-tickets 1.0.0 → 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.
- package/LICENSE.txt +0 -0
- package/README.md +307 -61
- package/SKILL.md +222 -46
- package/backup-tickets.sh +0 -0
- package/bug-report-widget.js +536 -0
- package/lib/storage/base-adapter.js +200 -0
- package/lib/storage/index.js +87 -0
- package/lib/storage/json-adapter.js +293 -0
- package/lib/storage/sqlite-adapter.js +552 -0
- package/lib/storage/supabase-adapter.js +614 -0
- package/package.json +21 -12
- package/setup.js +9 -11
- package/ticket-cli.js +0 -0
- package/ticket-server.js +425 -269
- package/ticket-tracker.html +459 -132
- package/tickets.example.json +0 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase Storage Adapter
|
|
3
|
+
* Uses Supabase client for cloud PostgreSQL storage
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const BaseAdapter = require('./base-adapter');
|
|
7
|
+
|
|
8
|
+
class SupabaseAdapter extends BaseAdapter {
|
|
9
|
+
constructor(config) {
|
|
10
|
+
super(config);
|
|
11
|
+
this.supabaseUrl = config.supabaseUrl;
|
|
12
|
+
this.supabaseKey = config.supabaseKey;
|
|
13
|
+
this.supabaseServiceKey = config.supabaseServiceKey;
|
|
14
|
+
this.client = null;
|
|
15
|
+
this.adminClient = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async initialize() {
|
|
19
|
+
// Dynamic import to make supabase optional
|
|
20
|
+
let createClient;
|
|
21
|
+
try {
|
|
22
|
+
const supabase = require('@supabase/supabase-js');
|
|
23
|
+
createClient = supabase.createClient;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'Supabase adapter requires @supabase/supabase-js. Install it with: npm install @supabase/supabase-js'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.client = createClient(this.supabaseUrl, this.supabaseKey);
|
|
31
|
+
|
|
32
|
+
// Create admin client if service key is provided (for table creation)
|
|
33
|
+
if (this.supabaseServiceKey) {
|
|
34
|
+
this.adminClient = createClient(this.supabaseUrl, this.supabaseServiceKey);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Try to create tables if we have admin access
|
|
38
|
+
await this._ensureTables();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async _ensureTables() {
|
|
42
|
+
// Check if tables exist by trying to query them
|
|
43
|
+
const { error } = await this.client.from('tickets').select('id').limit(1);
|
|
44
|
+
|
|
45
|
+
if (error && error.code === '42P01') {
|
|
46
|
+
// Table doesn't exist
|
|
47
|
+
if (this.adminClient) {
|
|
48
|
+
console.log('Creating Supabase tables...');
|
|
49
|
+
await this._createTables();
|
|
50
|
+
} else {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Supabase tables do not exist. Either:\n' +
|
|
53
|
+
'1. Set SUPABASE_SERVICE_ROLE_KEY for auto-creation, or\n' +
|
|
54
|
+
'2. Run the migration script manually. See README for SQL.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async _createTables() {
|
|
61
|
+
// Execute table creation via Supabase SQL
|
|
62
|
+
const { error } = await this.adminClient.rpc('exec_sql', {
|
|
63
|
+
sql: this._getCreateTablesSql()
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (error) {
|
|
67
|
+
// RPC might not exist, try alternative approach
|
|
68
|
+
console.warn('Could not auto-create tables via RPC. Please create them manually.');
|
|
69
|
+
console.log('SQL Schema:\n', this._getCreateTablesSql());
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_getCreateTablesSql() {
|
|
74
|
+
return `
|
|
75
|
+
-- Main tickets table
|
|
76
|
+
CREATE TABLE IF NOT EXISTS tickets (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
route TEXT NOT NULL,
|
|
79
|
+
f12_errors TEXT DEFAULT '',
|
|
80
|
+
server_errors TEXT DEFAULT '',
|
|
81
|
+
description TEXT DEFAULT '',
|
|
82
|
+
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in-progress', 'fixed', 'closed')),
|
|
83
|
+
priority TEXT CHECK (priority IS NULL OR priority IN ('critical', 'high', 'medium', 'low')),
|
|
84
|
+
namespace TEXT,
|
|
85
|
+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
86
|
+
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
-- Related tickets junction table
|
|
90
|
+
CREATE TABLE IF NOT EXISTS ticket_relations (
|
|
91
|
+
id SERIAL PRIMARY KEY,
|
|
92
|
+
ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
93
|
+
related_ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
94
|
+
UNIQUE(ticket_id, related_ticket_id)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
-- Swarm actions log
|
|
98
|
+
CREATE TABLE IF NOT EXISTS swarm_actions (
|
|
99
|
+
id SERIAL PRIMARY KEY,
|
|
100
|
+
ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
101
|
+
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
102
|
+
action TEXT NOT NULL,
|
|
103
|
+
result TEXT
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
-- Comments table
|
|
107
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
108
|
+
id TEXT PRIMARY KEY,
|
|
109
|
+
ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
|
110
|
+
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
111
|
+
type TEXT NOT NULL DEFAULT 'human' CHECK (type IN ('human', 'ai')),
|
|
112
|
+
author TEXT DEFAULT 'anonymous',
|
|
113
|
+
content TEXT DEFAULT '',
|
|
114
|
+
metadata JSONB DEFAULT '{}',
|
|
115
|
+
edited_at TIMESTAMP WITH TIME ZONE
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
-- API Keys for bug report widget
|
|
119
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
120
|
+
id SERIAL PRIMARY KEY,
|
|
121
|
+
key TEXT UNIQUE NOT NULL,
|
|
122
|
+
name TEXT,
|
|
123
|
+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
124
|
+
last_used TIMESTAMP WITH TIME ZONE,
|
|
125
|
+
rate_limit INTEGER DEFAULT 100,
|
|
126
|
+
enabled BOOLEAN DEFAULT true
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
-- Rate limiting table
|
|
130
|
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
131
|
+
id SERIAL PRIMARY KEY,
|
|
132
|
+
identifier TEXT NOT NULL,
|
|
133
|
+
window_start TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
134
|
+
request_count INTEGER DEFAULT 1,
|
|
135
|
+
UNIQUE(identifier, window_start)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
-- Create indexes
|
|
139
|
+
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_tickets_route ON tickets(route);
|
|
142
|
+
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_swarm_actions_ticket_id ON swarm_actions(ticket_id);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_comments_ticket_id ON comments(ticket_id);
|
|
145
|
+
|
|
146
|
+
-- Enable Row Level Security (optional)
|
|
147
|
+
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;
|
|
148
|
+
ALTER TABLE swarm_actions ENABLE ROW LEVEL SECURITY;
|
|
149
|
+
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
|
|
150
|
+
|
|
151
|
+
-- Create policies for anonymous access (adjust as needed)
|
|
152
|
+
CREATE POLICY IF NOT EXISTS "Allow all for now" ON tickets FOR ALL USING (true);
|
|
153
|
+
CREATE POLICY IF NOT EXISTS "Allow all for now" ON swarm_actions FOR ALL USING (true);
|
|
154
|
+
CREATE POLICY IF NOT EXISTS "Allow all for now" ON comments FOR ALL USING (true);
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async close() {
|
|
159
|
+
// Supabase client doesn't need explicit closing
|
|
160
|
+
this.client = null;
|
|
161
|
+
this.adminClient = null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Helper to convert DB row to ticket format
|
|
165
|
+
_rowToTicket(row, swarmActions = [], comments = [], relatedTickets = []) {
|
|
166
|
+
return {
|
|
167
|
+
id: row.id,
|
|
168
|
+
route: row.route,
|
|
169
|
+
f12Errors: row.f12_errors,
|
|
170
|
+
serverErrors: row.server_errors,
|
|
171
|
+
description: row.description,
|
|
172
|
+
status: row.status,
|
|
173
|
+
priority: row.priority,
|
|
174
|
+
namespace: row.namespace,
|
|
175
|
+
createdAt: row.created_at,
|
|
176
|
+
updatedAt: row.updated_at,
|
|
177
|
+
swarmActions: swarmActions.map(a => ({
|
|
178
|
+
timestamp: a.timestamp,
|
|
179
|
+
action: a.action,
|
|
180
|
+
result: a.result
|
|
181
|
+
})),
|
|
182
|
+
comments: comments.map(c => ({
|
|
183
|
+
id: c.id,
|
|
184
|
+
timestamp: c.timestamp,
|
|
185
|
+
type: c.type,
|
|
186
|
+
author: c.author,
|
|
187
|
+
content: c.content,
|
|
188
|
+
metadata: c.metadata || {},
|
|
189
|
+
editedAt: c.edited_at
|
|
190
|
+
})),
|
|
191
|
+
relatedTickets: relatedTickets.map(r => r.related_ticket_id)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async _getFullTicket(id) {
|
|
196
|
+
const [ticketResult, actionsResult, commentsResult, relationsResult] = await Promise.all([
|
|
197
|
+
this.client.from('tickets').select('*').eq('id', id).single(),
|
|
198
|
+
this.client.from('swarm_actions').select('*').eq('ticket_id', id).order('timestamp'),
|
|
199
|
+
this.client.from('comments').select('*').eq('ticket_id', id).order('timestamp'),
|
|
200
|
+
this.client.from('ticket_relations').select('related_ticket_id').eq('ticket_id', id)
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
if (ticketResult.error || !ticketResult.data) return null;
|
|
204
|
+
|
|
205
|
+
return this._rowToTicket(
|
|
206
|
+
ticketResult.data,
|
|
207
|
+
actionsResult.data || [],
|
|
208
|
+
commentsResult.data || [],
|
|
209
|
+
relationsResult.data || []
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ==================== TICKET OPERATIONS ====================
|
|
214
|
+
|
|
215
|
+
async getAllTickets(filters = {}) {
|
|
216
|
+
let query = this.client.from('tickets').select('*');
|
|
217
|
+
|
|
218
|
+
if (filters.status) {
|
|
219
|
+
query = query.eq('status', filters.status);
|
|
220
|
+
}
|
|
221
|
+
if (filters.priority) {
|
|
222
|
+
query = query.eq('priority', filters.priority);
|
|
223
|
+
}
|
|
224
|
+
if (filters.route) {
|
|
225
|
+
query = query.ilike('route', `%${filters.route}%`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
query = query.order('created_at', { ascending: false });
|
|
229
|
+
|
|
230
|
+
const { data, error } = await query;
|
|
231
|
+
if (error) throw error;
|
|
232
|
+
|
|
233
|
+
// Get all related data for each ticket
|
|
234
|
+
const tickets = await Promise.all(
|
|
235
|
+
(data || []).map(row => this._getFullTicket(row.id))
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return tickets.filter(t => t !== null);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async getTicket(id) {
|
|
242
|
+
return this._getFullTicket(id);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async createTicket(ticketData) {
|
|
246
|
+
const id = this.generateTicketId();
|
|
247
|
+
const now = new Date().toISOString();
|
|
248
|
+
|
|
249
|
+
const ticket = {
|
|
250
|
+
id,
|
|
251
|
+
route: ticketData.route || '',
|
|
252
|
+
f12_errors: ticketData.f12Errors || '',
|
|
253
|
+
server_errors: ticketData.serverErrors || '',
|
|
254
|
+
description: ticketData.description || '',
|
|
255
|
+
status: ticketData.status || 'open',
|
|
256
|
+
priority: ticketData.priority || null,
|
|
257
|
+
namespace: ticketData.namespace || null,
|
|
258
|
+
created_at: now,
|
|
259
|
+
updated_at: now
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const { error } = await this.client.from('tickets').insert(ticket);
|
|
263
|
+
if (error) throw error;
|
|
264
|
+
|
|
265
|
+
// Insert related tickets
|
|
266
|
+
if (ticketData.relatedTickets && ticketData.relatedTickets.length > 0) {
|
|
267
|
+
const relations = ticketData.relatedTickets.map(relatedId => ({
|
|
268
|
+
ticket_id: id,
|
|
269
|
+
related_ticket_id: relatedId
|
|
270
|
+
}));
|
|
271
|
+
await this.client.from('ticket_relations').insert(relations);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Insert swarm actions
|
|
275
|
+
if (ticketData.swarmActions && ticketData.swarmActions.length > 0) {
|
|
276
|
+
const actions = ticketData.swarmActions.map(action => ({
|
|
277
|
+
ticket_id: id,
|
|
278
|
+
timestamp: action.timestamp || now,
|
|
279
|
+
action: action.action,
|
|
280
|
+
result: action.result || null
|
|
281
|
+
}));
|
|
282
|
+
await this.client.from('swarm_actions').insert(actions);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Insert comments
|
|
286
|
+
if (ticketData.comments && ticketData.comments.length > 0) {
|
|
287
|
+
const comments = ticketData.comments.map(comment => ({
|
|
288
|
+
id: comment.id || this.generateCommentId(),
|
|
289
|
+
ticket_id: id,
|
|
290
|
+
timestamp: comment.timestamp || now,
|
|
291
|
+
type: comment.type || 'human',
|
|
292
|
+
author: comment.author || 'anonymous',
|
|
293
|
+
content: comment.content || '',
|
|
294
|
+
metadata: comment.metadata || {}
|
|
295
|
+
}));
|
|
296
|
+
await this.client.from('comments').insert(comments);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return this.getTicket(id);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async updateTicket(id, updates) {
|
|
303
|
+
const existing = await this.getTicket(id);
|
|
304
|
+
if (!existing) return null;
|
|
305
|
+
|
|
306
|
+
const now = new Date().toISOString();
|
|
307
|
+
|
|
308
|
+
const updateData = { updated_at: now };
|
|
309
|
+
if (updates.route !== undefined) updateData.route = updates.route;
|
|
310
|
+
if (updates.f12Errors !== undefined) updateData.f12_errors = updates.f12Errors;
|
|
311
|
+
if (updates.serverErrors !== undefined) updateData.server_errors = updates.serverErrors;
|
|
312
|
+
if (updates.description !== undefined) updateData.description = updates.description;
|
|
313
|
+
if (updates.status !== undefined) updateData.status = updates.status;
|
|
314
|
+
if (updates.priority !== undefined) updateData.priority = updates.priority;
|
|
315
|
+
if (updates.namespace !== undefined) updateData.namespace = updates.namespace;
|
|
316
|
+
|
|
317
|
+
const { error } = await this.client.from('tickets').update(updateData).eq('id', id);
|
|
318
|
+
if (error) throw error;
|
|
319
|
+
|
|
320
|
+
// Update related tickets if provided
|
|
321
|
+
if (updates.relatedTickets !== undefined) {
|
|
322
|
+
await this.client.from('ticket_relations').delete().eq('ticket_id', id);
|
|
323
|
+
if (updates.relatedTickets.length > 0) {
|
|
324
|
+
const relations = updates.relatedTickets.map(relatedId => ({
|
|
325
|
+
ticket_id: id,
|
|
326
|
+
related_ticket_id: relatedId
|
|
327
|
+
}));
|
|
328
|
+
await this.client.from('ticket_relations').insert(relations);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return this.getTicket(id);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async deleteTicket(id) {
|
|
336
|
+
const { error, count } = await this.client.from('tickets').delete().eq('id', id);
|
|
337
|
+
if (error) throw error;
|
|
338
|
+
return count > 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ==================== SWARM ACTION OPERATIONS ====================
|
|
342
|
+
|
|
343
|
+
async addSwarmAction(ticketId, action) {
|
|
344
|
+
const ticket = await this.getTicket(ticketId);
|
|
345
|
+
if (!ticket) return null;
|
|
346
|
+
|
|
347
|
+
const now = new Date().toISOString();
|
|
348
|
+
|
|
349
|
+
const { error } = await this.client.from('swarm_actions').insert({
|
|
350
|
+
ticket_id: ticketId,
|
|
351
|
+
timestamp: now,
|
|
352
|
+
action: action.action,
|
|
353
|
+
result: action.result || null
|
|
354
|
+
});
|
|
355
|
+
if (error) throw error;
|
|
356
|
+
|
|
357
|
+
await this.client.from('tickets').update({ updated_at: now }).eq('id', 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: commentData.metadata || {}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const { error } = await this.client.from('comments').insert(comment);
|
|
382
|
+
if (error) throw error;
|
|
383
|
+
|
|
384
|
+
await this.client.from('tickets').update({ updated_at: now }).eq('id', ticketId);
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
id: commentId,
|
|
388
|
+
timestamp: now,
|
|
389
|
+
type: comment.type,
|
|
390
|
+
author: comment.author,
|
|
391
|
+
content: comment.content,
|
|
392
|
+
metadata: comment.metadata
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async getComments(ticketId) {
|
|
397
|
+
const { data, error } = await this.client
|
|
398
|
+
.from('comments')
|
|
399
|
+
.select('*')
|
|
400
|
+
.eq('ticket_id', ticketId)
|
|
401
|
+
.order('timestamp');
|
|
402
|
+
|
|
403
|
+
if (error) throw error;
|
|
404
|
+
|
|
405
|
+
return (data || []).map(c => ({
|
|
406
|
+
id: c.id,
|
|
407
|
+
timestamp: c.timestamp,
|
|
408
|
+
type: c.type,
|
|
409
|
+
author: c.author,
|
|
410
|
+
content: c.content,
|
|
411
|
+
metadata: c.metadata || {},
|
|
412
|
+
editedAt: c.edited_at
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async updateComment(ticketId, commentId, updates) {
|
|
417
|
+
const { data: existing } = await this.client
|
|
418
|
+
.from('comments')
|
|
419
|
+
.select('*')
|
|
420
|
+
.eq('id', commentId)
|
|
421
|
+
.eq('ticket_id', ticketId)
|
|
422
|
+
.single();
|
|
423
|
+
|
|
424
|
+
if (!existing) return null;
|
|
425
|
+
|
|
426
|
+
const now = new Date().toISOString();
|
|
427
|
+
|
|
428
|
+
const updateData = { edited_at: now };
|
|
429
|
+
if (updates.content !== undefined) updateData.content = updates.content;
|
|
430
|
+
if (updates.metadata !== undefined) updateData.metadata = { ...existing.metadata, ...updates.metadata };
|
|
431
|
+
|
|
432
|
+
const { error } = await this.client
|
|
433
|
+
.from('comments')
|
|
434
|
+
.update(updateData)
|
|
435
|
+
.eq('id', commentId)
|
|
436
|
+
.eq('ticket_id', ticketId);
|
|
437
|
+
|
|
438
|
+
if (error) throw error;
|
|
439
|
+
|
|
440
|
+
await this.client.from('tickets').update({ updated_at: now }).eq('id', ticketId);
|
|
441
|
+
|
|
442
|
+
const { data: updated } = await this.client
|
|
443
|
+
.from('comments')
|
|
444
|
+
.select('*')
|
|
445
|
+
.eq('id', commentId)
|
|
446
|
+
.single();
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
id: updated.id,
|
|
450
|
+
timestamp: updated.timestamp,
|
|
451
|
+
type: updated.type,
|
|
452
|
+
author: updated.author,
|
|
453
|
+
content: updated.content,
|
|
454
|
+
metadata: updated.metadata || {},
|
|
455
|
+
editedAt: updated.edited_at
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async deleteComment(ticketId, commentId) {
|
|
460
|
+
const { count, error } = await this.client
|
|
461
|
+
.from('comments')
|
|
462
|
+
.delete()
|
|
463
|
+
.eq('id', commentId)
|
|
464
|
+
.eq('ticket_id', ticketId);
|
|
465
|
+
|
|
466
|
+
if (error) throw error;
|
|
467
|
+
|
|
468
|
+
if (count > 0) {
|
|
469
|
+
await this.client.from('tickets').update({ updated_at: new Date().toISOString() }).eq('id', ticketId);
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ==================== STATS OPERATIONS ====================
|
|
476
|
+
|
|
477
|
+
async getStats() {
|
|
478
|
+
const { data: tickets, error } = await this.client.from('tickets').select('status, priority');
|
|
479
|
+
if (error) throw error;
|
|
480
|
+
|
|
481
|
+
const byStatus = { open: 0, inProgress: 0, fixed: 0, closed: 0 };
|
|
482
|
+
const byPriority = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
483
|
+
|
|
484
|
+
(tickets || []).forEach(t => {
|
|
485
|
+
if (t.status === 'in-progress') byStatus.inProgress++;
|
|
486
|
+
else if (byStatus[t.status] !== undefined) byStatus[t.status]++;
|
|
487
|
+
|
|
488
|
+
if (t.priority && byPriority[t.priority] !== undefined) {
|
|
489
|
+
byPriority[t.priority]++;
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
total: (tickets || []).length,
|
|
495
|
+
byStatus,
|
|
496
|
+
byPriority
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ==================== BUG REPORT OPERATIONS ====================
|
|
501
|
+
|
|
502
|
+
async createBugReport(reportData, apiKey = null) {
|
|
503
|
+
// Validate API key if provided
|
|
504
|
+
if (apiKey) {
|
|
505
|
+
const { data: keyRecord } = await this.client
|
|
506
|
+
.from('api_keys')
|
|
507
|
+
.select('*')
|
|
508
|
+
.eq('key', apiKey)
|
|
509
|
+
.eq('enabled', true)
|
|
510
|
+
.single();
|
|
511
|
+
|
|
512
|
+
if (!keyRecord) {
|
|
513
|
+
throw new Error('Invalid API key');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Update last used
|
|
517
|
+
await this.client.from('api_keys').update({ last_used: new Date().toISOString() }).eq('key', apiKey);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Rate limiting
|
|
521
|
+
const identifier = apiKey || reportData.ip || 'anonymous';
|
|
522
|
+
const windowStart = new Date();
|
|
523
|
+
windowStart.setMinutes(0, 0, 0);
|
|
524
|
+
const windowKey = windowStart.toISOString();
|
|
525
|
+
|
|
526
|
+
// Check current count
|
|
527
|
+
const { data: rateLimit } = await this.client
|
|
528
|
+
.from('rate_limits')
|
|
529
|
+
.select('request_count')
|
|
530
|
+
.eq('identifier', identifier)
|
|
531
|
+
.eq('window_start', windowKey)
|
|
532
|
+
.single();
|
|
533
|
+
|
|
534
|
+
const limit = apiKey ? 1000 : 10;
|
|
535
|
+
if (rateLimit && rateLimit.request_count >= limit) {
|
|
536
|
+
throw new Error('Rate limit exceeded. Please try again later.');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Increment rate limit
|
|
540
|
+
if (rateLimit) {
|
|
541
|
+
await this.client
|
|
542
|
+
.from('rate_limits')
|
|
543
|
+
.update({ request_count: rateLimit.request_count + 1 })
|
|
544
|
+
.eq('identifier', identifier)
|
|
545
|
+
.eq('window_start', windowKey);
|
|
546
|
+
} else {
|
|
547
|
+
await this.client.from('rate_limits').insert({
|
|
548
|
+
identifier,
|
|
549
|
+
window_start: windowKey,
|
|
550
|
+
request_count: 1
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Create the ticket
|
|
555
|
+
const ticket = await this.createTicket({
|
|
556
|
+
route: reportData.location || reportData.route || 'unknown',
|
|
557
|
+
f12Errors: reportData.clientError || reportData.f12Errors || '',
|
|
558
|
+
serverErrors: '',
|
|
559
|
+
description: reportData.description || '',
|
|
560
|
+
status: 'open',
|
|
561
|
+
priority: null,
|
|
562
|
+
swarmActions: [{
|
|
563
|
+
timestamp: new Date().toISOString(),
|
|
564
|
+
action: 'bug-report-submitted',
|
|
565
|
+
result: `Bug report submitted via widget. User agent: ${reportData.userAgent || 'unknown'}`
|
|
566
|
+
}]
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
id: ticket.id,
|
|
571
|
+
status: 'submitted',
|
|
572
|
+
message: 'Bug report received. Thank you!'
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ==================== API KEY MANAGEMENT ====================
|
|
577
|
+
|
|
578
|
+
async createApiKey(name = null) {
|
|
579
|
+
const crypto = require('crypto');
|
|
580
|
+
const key = 'stk_' + crypto.randomBytes(24).toString('hex');
|
|
581
|
+
const now = new Date().toISOString();
|
|
582
|
+
|
|
583
|
+
const { error } = await this.client.from('api_keys').insert({
|
|
584
|
+
key,
|
|
585
|
+
name,
|
|
586
|
+
created_at: now
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
if (error) throw error;
|
|
590
|
+
|
|
591
|
+
return { key, name, createdAt: now };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async listApiKeys() {
|
|
595
|
+
const { data, error } = await this.client
|
|
596
|
+
.from('api_keys')
|
|
597
|
+
.select('id, name, created_at, last_used, enabled');
|
|
598
|
+
|
|
599
|
+
if (error) throw error;
|
|
600
|
+
return data || [];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async revokeApiKey(key) {
|
|
604
|
+
const { count, error } = await this.client
|
|
605
|
+
.from('api_keys')
|
|
606
|
+
.update({ enabled: false })
|
|
607
|
+
.eq('key', key);
|
|
608
|
+
|
|
609
|
+
if (error) throw error;
|
|
610
|
+
return count > 0;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
module.exports = SupabaseAdapter;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "swarm-tickets",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Lightweight ticket tracking system for AI-powered bug fixing with Claude-flow/Claude Code",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Lightweight ticket tracking system for AI-powered bug fixing with Claude-flow/Claude Code. Supports JSON, SQLite, and Supabase storage.",
|
|
5
5
|
"main": "ticket-server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"swarm-tickets": "./ticket-server.js"
|
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
"ticket-server.js",
|
|
16
16
|
"ticket-cli.js",
|
|
17
17
|
"ticket-tracker.html",
|
|
18
|
+
"bug-report-widget.js",
|
|
18
19
|
"backup-tickets.sh",
|
|
19
20
|
"tickets.example.json",
|
|
20
21
|
"setup.js",
|
|
22
|
+
"lib/",
|
|
21
23
|
"SKILL.md",
|
|
22
24
|
"README.md",
|
|
23
25
|
"LICENSE"
|
|
@@ -32,21 +34,28 @@
|
|
|
32
34
|
"claude-code",
|
|
33
35
|
"ai",
|
|
34
36
|
"automation",
|
|
35
|
-
"development-tools"
|
|
37
|
+
"development-tools",
|
|
38
|
+
"sqlite",
|
|
39
|
+
"supabase",
|
|
40
|
+
"bug-report"
|
|
36
41
|
],
|
|
37
42
|
"dependencies": {
|
|
38
43
|
"express": "^4.18.2",
|
|
39
44
|
"cors": "^2.8.5"
|
|
40
45
|
},
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
},
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
46
|
+
"optionalDependencies": {
|
|
47
|
+
"better-sqlite3": "^9.4.0",
|
|
48
|
+
"@supabase/supabase-js": "^2.39.0"
|
|
49
|
+
},
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/AIWhispererGal/swarm-tickets.git"
|
|
53
|
+
},
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/AIWhispererGal/swarm-tickets/issues"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://github.com/AIWhispererGal/swarm-tickets#readme",
|
|
58
|
+
"author": "AIWhispererGal and Claude",
|
|
50
59
|
"license": "MIT",
|
|
51
60
|
"engines": {
|
|
52
61
|
"node": ">=14.0.0"
|