swarm-tickets 1.0.1 → 2.0.2

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,200 @@
1
+ /**
2
+ * Base Storage Adapter
3
+ * Abstract class defining the interface for all storage backends
4
+ */
5
+
6
+ class BaseAdapter {
7
+ constructor(config) {
8
+ this.config = config;
9
+ if (new.target === BaseAdapter) {
10
+ throw new Error('BaseAdapter is abstract and cannot be instantiated directly');
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Initialize the storage (create tables, files, etc.)
16
+ * @returns {Promise<void>}
17
+ */
18
+ async initialize() {
19
+ throw new Error('initialize() must be implemented');
20
+ }
21
+
22
+ /**
23
+ * Close the storage connection
24
+ * @returns {Promise<void>}
25
+ */
26
+ async close() {
27
+ throw new Error('close() must be implemented');
28
+ }
29
+
30
+ // ==================== TICKET OPERATIONS ====================
31
+
32
+ /**
33
+ * Get all tickets
34
+ * @param {Object} filters - Optional filters (status, priority, route)
35
+ * @returns {Promise<Array>} Array of tickets
36
+ */
37
+ async getAllTickets(filters = {}) {
38
+ throw new Error('getAllTickets() must be implemented');
39
+ }
40
+
41
+ /**
42
+ * Get a single ticket by ID
43
+ * @param {string} id - Ticket ID
44
+ * @returns {Promise<Object|null>} Ticket or null if not found
45
+ */
46
+ async getTicket(id) {
47
+ throw new Error('getTicket() must be implemented');
48
+ }
49
+
50
+ /**
51
+ * Create a new ticket
52
+ * @param {Object} ticketData - Ticket data
53
+ * @returns {Promise<Object>} Created ticket with ID
54
+ */
55
+ async createTicket(ticketData) {
56
+ throw new Error('createTicket() must be implemented');
57
+ }
58
+
59
+ /**
60
+ * Update a ticket
61
+ * @param {string} id - Ticket ID
62
+ * @param {Object} updates - Fields to update
63
+ * @returns {Promise<Object|null>} Updated ticket or null if not found
64
+ */
65
+ async updateTicket(id, updates) {
66
+ throw new Error('updateTicket() must be implemented');
67
+ }
68
+
69
+ /**
70
+ * Delete a ticket
71
+ * @param {string} id - Ticket ID
72
+ * @returns {Promise<boolean>} True if deleted
73
+ */
74
+ async deleteTicket(id) {
75
+ throw new Error('deleteTicket() must be implemented');
76
+ }
77
+
78
+ // ==================== SWARM ACTION OPERATIONS ====================
79
+
80
+ /**
81
+ * Add a swarm action to a ticket
82
+ * @param {string} ticketId - Ticket ID
83
+ * @param {Object} action - Action data (action, result)
84
+ * @returns {Promise<Object>} Updated ticket
85
+ */
86
+ async addSwarmAction(ticketId, action) {
87
+ throw new Error('addSwarmAction() must be implemented');
88
+ }
89
+
90
+ // ==================== COMMENT OPERATIONS ====================
91
+
92
+ /**
93
+ * Add a comment to a ticket
94
+ * @param {string} ticketId - Ticket ID
95
+ * @param {Object} comment - Comment data (author, content, type)
96
+ * @returns {Promise<Object>} Created comment
97
+ */
98
+ async addComment(ticketId, comment) {
99
+ throw new Error('addComment() must be implemented');
100
+ }
101
+
102
+ /**
103
+ * Get all comments for a ticket
104
+ * @param {string} ticketId - Ticket ID
105
+ * @returns {Promise<Array>} Array of comments
106
+ */
107
+ async getComments(ticketId) {
108
+ throw new Error('getComments() must be implemented');
109
+ }
110
+
111
+ /**
112
+ * Update a comment
113
+ * @param {string} ticketId - Ticket ID
114
+ * @param {string} commentId - Comment ID
115
+ * @param {Object} updates - Fields to update
116
+ * @returns {Promise<Object|null>} Updated comment or null
117
+ */
118
+ async updateComment(ticketId, commentId, updates) {
119
+ throw new Error('updateComment() must be implemented');
120
+ }
121
+
122
+ /**
123
+ * Delete a comment
124
+ * @param {string} ticketId - Ticket ID
125
+ * @param {string} commentId - Comment ID
126
+ * @returns {Promise<boolean>} True if deleted
127
+ */
128
+ async deleteComment(ticketId, commentId) {
129
+ throw new Error('deleteComment() must be implemented');
130
+ }
131
+
132
+ // ==================== STATS OPERATIONS ====================
133
+
134
+ /**
135
+ * Get ticket statistics
136
+ * @returns {Promise<Object>} Statistics object
137
+ */
138
+ async getStats() {
139
+ throw new Error('getStats() must be implemented');
140
+ }
141
+
142
+ // ==================== BUG REPORT OPERATIONS ====================
143
+
144
+ /**
145
+ * Create a bug report (limited access endpoint)
146
+ * @param {Object} reportData - Bug report data
147
+ * @param {string} apiKey - Optional API key for validation
148
+ * @returns {Promise<Object>} Created ticket (limited info)
149
+ */
150
+ async createBugReport(reportData, apiKey = null) {
151
+ throw new Error('createBugReport() must be implemented');
152
+ }
153
+
154
+ // ==================== UTILITY METHODS ====================
155
+
156
+ /**
157
+ * Generate a unique ticket ID
158
+ * @returns {string} Ticket ID
159
+ */
160
+ generateTicketId() {
161
+ return 'TKT-' + Date.now();
162
+ }
163
+
164
+ /**
165
+ * Generate a unique comment ID
166
+ * @returns {string} Comment ID
167
+ */
168
+ generateCommentId() {
169
+ return 'CMT-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
170
+ }
171
+
172
+ /**
173
+ * Validate ticket status
174
+ * @param {string} status - Status to validate
175
+ * @returns {boolean} True if valid
176
+ */
177
+ isValidStatus(status) {
178
+ return ['open', 'in-progress', 'fixed', 'closed'].includes(status);
179
+ }
180
+
181
+ /**
182
+ * Validate ticket priority
183
+ * @param {string} priority - Priority to validate
184
+ * @returns {boolean} True if valid
185
+ */
186
+ isValidPriority(priority) {
187
+ return ['critical', 'high', 'medium', 'low'].includes(priority);
188
+ }
189
+
190
+ /**
191
+ * Create a backup (if supported)
192
+ * @returns {Promise<string|null>} Backup path or null
193
+ */
194
+ async createBackup() {
195
+ // Default: no backup support
196
+ return null;
197
+ }
198
+ }
199
+
200
+ module.exports = BaseAdapter;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Storage Adapter Factory
3
+ * Supports JSON, SQLite, and Supabase backends
4
+ */
5
+
6
+ const JsonAdapter = require('./json-adapter');
7
+ const SqliteAdapter = require('./sqlite-adapter');
8
+ const SupabaseAdapter = require('./supabase-adapter');
9
+
10
+ /**
11
+ * Get storage configuration from environment or config file
12
+ */
13
+ function getStorageConfig() {
14
+ // Check environment variables first
15
+ const storageType = process.env.SWARM_TICKETS_STORAGE || 'json';
16
+
17
+ const config = {
18
+ type: storageType,
19
+ // JSON options
20
+ jsonPath: process.env.SWARM_TICKETS_JSON_PATH || './tickets.json',
21
+ backupDir: process.env.SWARM_TICKETS_BACKUP_DIR || './ticket-backups',
22
+
23
+ // SQLite options
24
+ sqlitePath: process.env.SWARM_TICKETS_SQLITE_PATH || './tickets.db',
25
+
26
+ // Supabase options
27
+ supabaseUrl: process.env.SUPABASE_URL || process.env.SWARM_TICKETS_SUPABASE_URL,
28
+ supabaseKey: process.env.SUPABASE_ANON_KEY || process.env.SWARM_TICKETS_SUPABASE_KEY,
29
+ supabaseServiceKey: process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SWARM_TICKETS_SUPABASE_SERVICE_KEY,
30
+ };
31
+
32
+ return config;
33
+ }
34
+
35
+ /**
36
+ * Create storage adapter based on configuration
37
+ * @param {Object} overrideConfig - Optional config override
38
+ * @returns {Promise<BaseAdapter>} Storage adapter instance
39
+ */
40
+ async function createStorageAdapter(overrideConfig = null) {
41
+ const config = overrideConfig || getStorageConfig();
42
+
43
+ let adapter;
44
+
45
+ switch (config.type.toLowerCase()) {
46
+ case 'sqlite':
47
+ adapter = new SqliteAdapter(config);
48
+ break;
49
+
50
+ case 'supabase':
51
+ if (!config.supabaseUrl || !config.supabaseKey) {
52
+ throw new Error(
53
+ 'Supabase configuration missing. Set SUPABASE_URL and SUPABASE_ANON_KEY environment variables.'
54
+ );
55
+ }
56
+ adapter = new SupabaseAdapter(config);
57
+ break;
58
+
59
+ case 'json':
60
+ default:
61
+ adapter = new JsonAdapter(config);
62
+ break;
63
+ }
64
+
65
+ // Initialize the adapter (create tables, etc.)
66
+ await adapter.initialize();
67
+
68
+ return adapter;
69
+ }
70
+
71
+ /**
72
+ * Storage type constants
73
+ */
74
+ const StorageType = {
75
+ JSON: 'json',
76
+ SQLITE: 'sqlite',
77
+ SUPABASE: 'supabase'
78
+ };
79
+
80
+ module.exports = {
81
+ createStorageAdapter,
82
+ getStorageConfig,
83
+ StorageType,
84
+ JsonAdapter,
85
+ SqliteAdapter,
86
+ SupabaseAdapter
87
+ };
@@ -0,0 +1,293 @@
1
+ /**
2
+ * JSON File Storage Adapter
3
+ * Backwards-compatible with existing tickets.json format
4
+ */
5
+
6
+ const fs = require('fs').promises;
7
+ const path = require('path');
8
+ const BaseAdapter = require('./base-adapter');
9
+
10
+ class JsonAdapter extends BaseAdapter {
11
+ constructor(config) {
12
+ super(config);
13
+ this.ticketsPath = path.resolve(config.jsonPath || './tickets.json');
14
+ this.backupDir = path.resolve(config.backupDir || './ticket-backups');
15
+ this.data = { tickets: [] };
16
+ }
17
+
18
+ async initialize() {
19
+ // Create backup directory
20
+ try {
21
+ await fs.mkdir(this.backupDir, { recursive: true });
22
+ } catch (error) {
23
+ // Directory may already exist
24
+ }
25
+
26
+ // Load existing data or create new file
27
+ try {
28
+ const content = await fs.readFile(this.ticketsPath, 'utf8');
29
+ this.data = JSON.parse(content);
30
+ // Ensure tickets array exists
31
+ if (!this.data.tickets) {
32
+ this.data.tickets = [];
33
+ }
34
+ // Migrate: add comments array to existing tickets
35
+ this.data.tickets = this.data.tickets.map(ticket => ({
36
+ ...ticket,
37
+ comments: ticket.comments || []
38
+ }));
39
+ } catch (error) {
40
+ // File doesn't exist, create empty structure
41
+ this.data = { tickets: [] };
42
+ await this._save();
43
+ }
44
+ }
45
+
46
+ async close() {
47
+ // No connection to close for file-based storage
48
+ }
49
+
50
+ async _save() {
51
+ await this.createBackup();
52
+ await fs.writeFile(this.ticketsPath, JSON.stringify(this.data, null, 2));
53
+ }
54
+
55
+ async createBackup() {
56
+ try {
57
+ await fs.access(this.ticketsPath);
58
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
59
+ const backupPath = path.join(this.backupDir, `tickets-${timestamp}.json`);
60
+ await fs.copyFile(this.ticketsPath, backupPath);
61
+
62
+ // Rotate backups - keep last 10
63
+ const files = await fs.readdir(this.backupDir);
64
+ const backupFiles = files
65
+ .filter(f => f.startsWith('tickets-') && f.endsWith('.json'))
66
+ .sort()
67
+ .reverse();
68
+
69
+ if (backupFiles.length > 10) {
70
+ for (let i = 10; i < backupFiles.length; i++) {
71
+ await fs.unlink(path.join(this.backupDir, backupFiles[i]));
72
+ }
73
+ }
74
+
75
+ return backupPath;
76
+ } catch (error) {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // ==================== TICKET OPERATIONS ====================
82
+
83
+ async getAllTickets(filters = {}) {
84
+ let tickets = [...this.data.tickets];
85
+
86
+ if (filters.status) {
87
+ tickets = tickets.filter(t => t.status === filters.status);
88
+ }
89
+ if (filters.priority) {
90
+ tickets = tickets.filter(t => t.priority === filters.priority);
91
+ }
92
+ if (filters.route) {
93
+ tickets = tickets.filter(t => t.route && t.route.includes(filters.route));
94
+ }
95
+
96
+ return tickets;
97
+ }
98
+
99
+ async getTicket(id) {
100
+ return this.data.tickets.find(t => t.id === id) || null;
101
+ }
102
+
103
+ async createTicket(ticketData) {
104
+ const ticket = {
105
+ id: this.generateTicketId(),
106
+ route: ticketData.route || '',
107
+ f12Errors: ticketData.f12Errors || '',
108
+ serverErrors: ticketData.serverErrors || '',
109
+ description: ticketData.description || '',
110
+ status: ticketData.status || 'open',
111
+ priority: ticketData.priority || null,
112
+ relatedTickets: ticketData.relatedTickets || [],
113
+ swarmActions: ticketData.swarmActions || [],
114
+ comments: ticketData.comments || [],
115
+ namespace: ticketData.namespace || null,
116
+ createdAt: new Date().toISOString(),
117
+ updatedAt: new Date().toISOString()
118
+ };
119
+
120
+ this.data.tickets.push(ticket);
121
+ await this._save();
122
+
123
+ return ticket;
124
+ }
125
+
126
+ async updateTicket(id, updates) {
127
+ const ticketIndex = this.data.tickets.findIndex(t => t.id === id);
128
+ if (ticketIndex === -1) return null;
129
+
130
+ const allowedFields = [
131
+ 'status', 'priority', 'relatedTickets', 'swarmActions',
132
+ 'namespace', 'description', 'f12Errors', 'serverErrors', 'route', 'comments'
133
+ ];
134
+
135
+ const ticket = this.data.tickets[ticketIndex];
136
+
137
+ allowedFields.forEach(field => {
138
+ if (updates[field] !== undefined) {
139
+ ticket[field] = updates[field];
140
+ }
141
+ });
142
+
143
+ ticket.updatedAt = new Date().toISOString();
144
+ await this._save();
145
+
146
+ return ticket;
147
+ }
148
+
149
+ async deleteTicket(id) {
150
+ const ticketIndex = this.data.tickets.findIndex(t => t.id === id);
151
+ if (ticketIndex === -1) return false;
152
+
153
+ this.data.tickets.splice(ticketIndex, 1);
154
+ await this._save();
155
+
156
+ return true;
157
+ }
158
+
159
+ // ==================== SWARM ACTION OPERATIONS ====================
160
+
161
+ async addSwarmAction(ticketId, action) {
162
+ const ticket = await this.getTicket(ticketId);
163
+ if (!ticket) return null;
164
+
165
+ const swarmAction = {
166
+ timestamp: new Date().toISOString(),
167
+ action: action.action,
168
+ result: action.result || null
169
+ };
170
+
171
+ ticket.swarmActions.push(swarmAction);
172
+ ticket.updatedAt = new Date().toISOString();
173
+
174
+ await this._save();
175
+ return ticket;
176
+ }
177
+
178
+ // ==================== COMMENT OPERATIONS ====================
179
+
180
+ async addComment(ticketId, commentData) {
181
+ const ticket = await this.getTicket(ticketId);
182
+ if (!ticket) return null;
183
+
184
+ // Ensure comments array exists
185
+ if (!ticket.comments) {
186
+ ticket.comments = [];
187
+ }
188
+
189
+ const comment = {
190
+ id: this.generateCommentId(),
191
+ timestamp: new Date().toISOString(),
192
+ type: commentData.type || 'human', // 'human' or 'ai'
193
+ author: commentData.author || 'anonymous',
194
+ content: commentData.content || '',
195
+ metadata: commentData.metadata || {}
196
+ };
197
+
198
+ ticket.comments.push(comment);
199
+ ticket.updatedAt = new Date().toISOString();
200
+
201
+ await this._save();
202
+ return comment;
203
+ }
204
+
205
+ async getComments(ticketId) {
206
+ const ticket = await this.getTicket(ticketId);
207
+ if (!ticket) return [];
208
+ return ticket.comments || [];
209
+ }
210
+
211
+ async updateComment(ticketId, commentId, updates) {
212
+ const ticket = await this.getTicket(ticketId);
213
+ if (!ticket || !ticket.comments) return null;
214
+
215
+ const commentIndex = ticket.comments.findIndex(c => c.id === commentId);
216
+ if (commentIndex === -1) return null;
217
+
218
+ const comment = ticket.comments[commentIndex];
219
+ if (updates.content !== undefined) comment.content = updates.content;
220
+ if (updates.metadata !== undefined) comment.metadata = { ...comment.metadata, ...updates.metadata };
221
+ comment.editedAt = new Date().toISOString();
222
+
223
+ ticket.updatedAt = new Date().toISOString();
224
+ await this._save();
225
+
226
+ return comment;
227
+ }
228
+
229
+ async deleteComment(ticketId, commentId) {
230
+ const ticket = await this.getTicket(ticketId);
231
+ if (!ticket || !ticket.comments) return false;
232
+
233
+ const commentIndex = ticket.comments.findIndex(c => c.id === commentId);
234
+ if (commentIndex === -1) return false;
235
+
236
+ ticket.comments.splice(commentIndex, 1);
237
+ ticket.updatedAt = new Date().toISOString();
238
+ await this._save();
239
+
240
+ return true;
241
+ }
242
+
243
+ // ==================== STATS OPERATIONS ====================
244
+
245
+ async getStats() {
246
+ const tickets = this.data.tickets;
247
+
248
+ return {
249
+ total: tickets.length,
250
+ byStatus: {
251
+ open: tickets.filter(t => t.status === 'open').length,
252
+ inProgress: tickets.filter(t => t.status === 'in-progress').length,
253
+ fixed: tickets.filter(t => t.status === 'fixed').length,
254
+ closed: tickets.filter(t => t.status === 'closed').length
255
+ },
256
+ byPriority: {
257
+ critical: tickets.filter(t => t.priority === 'critical').length,
258
+ high: tickets.filter(t => t.priority === 'high').length,
259
+ medium: tickets.filter(t => t.priority === 'medium').length,
260
+ low: tickets.filter(t => t.priority === 'low').length
261
+ }
262
+ };
263
+ }
264
+
265
+ // ==================== BUG REPORT OPERATIONS ====================
266
+
267
+ async createBugReport(reportData, apiKey = null) {
268
+ // For JSON adapter, we don't validate API keys (simple mode)
269
+ // Create a minimal ticket from the bug report
270
+ const ticket = await this.createTicket({
271
+ route: reportData.location || reportData.route || 'unknown',
272
+ f12Errors: reportData.clientError || reportData.f12Errors || '',
273
+ serverErrors: '', // Bug reports don't include server errors
274
+ description: reportData.description || '',
275
+ status: 'open',
276
+ priority: null, // Will be set by triage
277
+ swarmActions: [{
278
+ timestamp: new Date().toISOString(),
279
+ action: 'bug-report-submitted',
280
+ result: `Bug report submitted via widget. User agent: ${reportData.userAgent || 'unknown'}`
281
+ }]
282
+ });
283
+
284
+ // Return limited info (don't expose internal ticket structure)
285
+ return {
286
+ id: ticket.id,
287
+ status: 'submitted',
288
+ message: 'Bug report received. Thank you!'
289
+ };
290
+ }
291
+ }
292
+
293
+ module.exports = JsonAdapter;