#!/usr/bin/env node // FreeScout time tracking CLI — query timelogs and generate invoice Excel const mysql = require('mysql2/promise'); const ExcelJS = require('exceljs'); const fs = require('fs'); const path = require('path'); const credFile = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'services.env'); const env = {}; fs.readFileSync(credFile, 'utf8').split('\n').forEach(l => { const m = l.match(/^(\w+)=(.+)$/); if (m) env[m[1]] = m[2]; }); async function getConn() { return mysql.createConnection({ host: env.CLOONAR_DB_HOST, port: parseInt(env.CLOONAR_DB_PORT || '3306'), user: env.CLOONAR_DB_USER, password: env.CLOONAR_DB_PASS, database: env.FREESCOUT_DB_NAME, }); } // Load custom field dropdown options (id -> {optionId: label}) async function loadCustomFieldOptions(conn) { const [fields] = await conn.query('SELECT id, name, options FROM custom_fields'); const map = {}; fields.forEach(f => { map[f.name.toLowerCase()] = { id: f.id, options: f.options ? JSON.parse(f.options) : {} }; }); return map; } function formatHours(seconds) { const h = seconds / 3600; return Math.round(h * 100) / 100; } // Get custom field values for conversations, resolved to labels async function getConversationCustomFields(conn, conversationIds, cfMap) { if (!conversationIds.length) return {}; const [rows] = await conn.query( 'SELECT conversation_id, custom_field_id, value FROM conversation_custom_field WHERE conversation_id IN (?)', [conversationIds] ); // Build: { convId: { fieldName: resolvedValue } } const result = {}; const idToField = {}; Object.entries(cfMap).forEach(([name, f]) => { idToField[f.id] = { name, options: f.options }; }); rows.forEach(r => { if (!result[r.conversation_id]) result[r.conversation_id] = {}; const field = idToField[r.custom_field_id]; if (field) { const resolved = field.options[r.value] || r.value || '-'; result[r.conversation_id][field.name] = resolved; } }); return result; } // --- Commands --- async function cmdMailboxes() { const conn = await getConn(); const [rows] = await conn.query('SELECT id, name FROM mailboxes ORDER BY name'); console.log('ID\tNAME'); rows.forEach(r => console.log(`${r.id}\t${r.name}`)); await conn.end(); } async function cmdUsers() { const conn = await getConn(); const [rows] = await conn.query('SELECT id, first_name, last_name, email FROM users ORDER BY first_name'); console.log('ID\tNAME\tEMAIL'); rows.forEach(r => console.log(`${r.id}\t${r.first_name} ${r.last_name}\t${r.email}`)); await conn.end(); } async function cmdReport(args) { const mailboxId = getFlag(args, '--mailbox'); const from = getFlag(args, '--from'); const to = getFlag(args, '--to'); const userId = getFlag(args, '--user'); if (!from || !to) { console.error('Usage: freescout report --from YYYY-MM-DD --to YYYY-MM-DD [--mailbox ID] [--user ID]'); process.exit(1); } const conn = await getConn(); const cfMap = await loadCustomFieldOptions(conn); let query = ` SELECT t.time_spent, t.created_at, c.id as conversation_id, c.number as conversation_number, c.subject, c.customer_email, m.name as mailbox_name, u.first_name, u.last_name FROM timelogs t JOIN conversations c ON t.conversation_id = c.id JOIN mailboxes m ON c.mailbox_id = m.id JOIN users u ON t.user_id = u.id WHERE t.created_at >= ? AND t.created_at < DATE_ADD(?, INTERVAL 1 DAY) `; const params = [from, to]; if (mailboxId) { query += ' AND c.mailbox_id = ?'; params.push(mailboxId); } if (userId) { query += ' AND t.user_id = ?'; params.push(userId); } query += ' ORDER BY t.created_at'; const [rows] = await conn.query(query, params); if (rows.length === 0) { console.log('No timelogs found for this period.'); await conn.end(); return; } const convIds = [...new Set(rows.map(r => r.conversation_id))]; const cfValues = await getConversationCustomFields(conn, convIds, cfMap); console.log('DATE\tCONV#\tSUBJECT\tCUSTOMER\tWEBSITE\tUSER\tHOURS'); let totalSeconds = 0; rows.forEach(r => { const date = new Date(r.created_at).toISOString().slice(0, 10); const hours = formatHours(r.time_spent); totalSeconds += r.time_spent; const cf = cfValues[r.conversation_id] || {}; console.log(`${date}\t#${r.conversation_number}\t${r.subject || '(no subject)'}\t${cf.customer || '-'}\t${cf.website || '-'}\t${r.first_name} ${r.last_name}\t${hours}`); }); console.log(`\nTotal: ${formatHours(totalSeconds)} hours (${rows.length} entries)`); await conn.end(); } async function cmdExcel(args) { const mailboxId = getFlag(args, '--mailbox'); const from = getFlag(args, '--from'); const to = getFlag(args, '--to'); const userId = getFlag(args, '--user'); const output = getFlag(args, '--output') || `invoice-${from}-${to}.xlsx`; if (!from || !to) { console.error('Usage: freescout excel --from YYYY-MM-DD --to YYYY-MM-DD [--mailbox ID] [--user ID] [--output file.xlsx]'); process.exit(1); } const conn = await getConn(); const cfMap = await loadCustomFieldOptions(conn); let query = ` SELECT t.time_spent, t.created_at, c.id as conversation_id, c.number as conversation_number, c.subject, c.customer_email FROM timelogs t JOIN conversations c ON t.conversation_id = c.id JOIN mailboxes m ON c.mailbox_id = m.id JOIN users u ON t.user_id = u.id WHERE t.created_at >= ? AND t.created_at < DATE_ADD(?, INTERVAL 1 DAY) `; const params = [from, to]; if (mailboxId) { query += ' AND c.mailbox_id = ?'; params.push(mailboxId); } if (userId) { query += ' AND t.user_id = ?'; params.push(userId); } query += ' ORDER BY t.created_at'; const [rows] = await conn.query(query, params); if (rows.length === 0) { console.log('No timelogs found for this period.'); await conn.end(); return; } const convIds = [...new Set(rows.map(r => r.conversation_id))]; const cfValues = await getConversationCustomFields(conn, convIds, cfMap); await conn.end(); // Group by conversation const byConv = {}; rows.forEach(r => { const key = r.conversation_number; if (!byConv[key]) { const cf = cfValues[r.conversation_id] || {}; byConv[key] = { number: r.conversation_number, subject: r.subject || '(no subject)', customer: cf.customer || '-', website: cf.website || '-', totalSeconds: 0, }; } byConv[key].totalSeconds += r.time_spent; }); // Build Excel const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet('Time Report'); ws.columns = [ { header: 'Conv #', key: 'number', width: 10 }, { header: 'Subject', key: 'subject', width: 40 }, { header: 'Customer', key: 'customer', width: 25 }, { header: 'Website', key: 'website', width: 25 }, { header: 'Hours', key: 'hours', width: 10 }, ]; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; let totalSeconds = 0; Object.values(byConv).forEach(conv => { totalSeconds += conv.totalSeconds; ws.addRow({ number: `#${conv.number}`, subject: conv.subject, customer: conv.customer, website: conv.website, hours: formatHours(conv.totalSeconds), }); }); const totalRow = ws.addRow({ number: '', subject: '', customer: '', website: 'TOTAL', hours: formatHours(totalSeconds) }); totalRow.font = { bold: true }; await wb.xlsx.writeFile(output); console.log(`Written: ${output}`); console.log(`${Object.keys(byConv).length} conversations, ${formatHours(totalSeconds)} hours total`); } // --- Helpers --- function getFlag(args, flag) { const idx = args.indexOf(flag); if (idx === -1) return null; return args[idx + 1] || null; } // --- Main --- const [cmd, ...args] = process.argv.slice(2); const commands_map = { mailboxes: cmdMailboxes, users: cmdUsers, report: cmdReport, excel: cmdExcel }; if (!cmd || !commands_map[cmd]) { console.log(`Usage: freescout [args] Commands: mailboxes List mailboxes users List users report --from DATE --to DATE Show time report (tab-separated) [--mailbox ID] [--user ID] excel --from DATE --to DATE Generate invoice Excel [--mailbox ID] [--user ID] [--output file.xlsx]`); process.exit(0); } commands_map[cmd](args).catch(e => { console.error(e.message); process.exit(1); });